Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 additions and 0 deletions

216
core/lib/Application.php Normal file
View 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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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();
}
}

View 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;
}
}

View 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}.*",
]);
}
}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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
View 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;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Exception;
use KTXF\Exception\BaseException;
class BadRequestException extends BaseException
{
}

View 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
{
}

View 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
{
}

View 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
{
}

View 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
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Exception;
use KTXF\Exception\RuntimeException;
class UnexpectedValueException extends RuntimeException {}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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);
}
};
}
}

View 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;
}

View 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);
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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 {}

View 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));
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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');
}
}

View 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 ? ']' : '}';
}
}

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,5 @@
<?php
namespace KTXC\Injection;
class Builder extends \DI\ContainerBuilder {}

View File

@@ -0,0 +1,5 @@
<?php
namespace KTXC\Injection;
class Container extends \DI\Container {}

494
core/lib/Kernel.php Normal file
View 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;
},
]);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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 [];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View 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]);
}
}

View 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;
}
}

View 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
View 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;
}
}

View 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],
);
}
}

View 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;
}
}

View 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);
}
}

View 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
View 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.'
);
}
}

View 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();
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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');
}
}
}

View 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
View 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);
}
}

View 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();
}
}

View 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]);
}
}

View 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;
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Module\ModuleManager;
use KTXF\Utile\UUID;
/**
* Role Store - Database operations for user roles
*/
class UserRolesStore
{
protected const COLLECTION_NAME = 'user_roles';
public function __construct(
protected readonly DataStore $store,
protected readonly ModuleManager $moduleManager
) {}
// =========================================================================
// Role Operations
// =========================================================================
/**
* List all roles for a tenant
*/
public function listRoles(string $tenant): array
{
$cursor = $this->store->selectCollection(self::COLLECTION_NAME)->find(
['tid' => $tenant],
['sort' => ['label' => 1]]
);
$roles = [];
foreach ($cursor as $entry) {
$role = (array)$entry;
// Ensure permissions is an array
if (isset($role['permissions'])) {
$role['permissions'] = (array)$role['permissions'];
}
$roles[] = $role;
}
return $roles;
}
/**
* Fetch role by tenant and role ID
*/
public function fetchByRid(string $tenant, string $rid): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenant,
'rid' => $rid
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
/**
* Create a new role
*/
public function createRole(string $tenant, array $roleData): array
{
$roleData['tid'] = $tenant;
$roleData['rid'] = $roleData['rid'] ?? UUID::v4();
$roleData['label'] = $roleData['label'] ?? 'Unnamed Role';
$roleData['description'] = $roleData['description'] ?? '';
$roleData['permissions'] = $roleData['permissions'] ?? [];
$roleData['system'] = $roleData['system'] ?? false;
$this->store->selectCollection(self::COLLECTION_NAME)->insertOne($roleData);
return $this->fetchByRid($tenant, $roleData['rid']);
}
/**
* Update an existing role
*/
public function updateRole(string $tenant, string $rid, array $updates): bool
{
// Prevent updating system flag
unset($updates['tid'], $updates['rid'], $updates['system']);
if (empty($updates)) {
return false;
}
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
['tid' => $tenant, 'rid' => $rid],
['$set' => $updates]
);
return $result->getModifiedCount() > 0;
}
/**
* Delete a role
*/
public function deleteRole(string $tenant, string $rid): bool
{
// Check if role is system role
$role = $this->fetchByRid($tenant, $rid);
if (!$role || ($role['system'] ?? false)) {
return false; // Cannot delete system roles
}
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([
'tid' => $tenant,
'rid' => $rid
]);
return $result->getDeletedCount() > 0;
}
/**
* Count users assigned to a role
*/
public function countUsersInRole(string $tenant, string $rid): int
{
$count = $this->store->selectCollection('user_accounts')->countDocuments([
'tid' => $tenant,
'roles' => $rid
]);
return (int)$count;
}
/**
* Get all available permissions from modules
* Grouped by category with metadata
*/
public function availablePermissions(): array
{
return $this->moduleManager->availablePermissions();
}
}

22
core/lib/index.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
use KTXC\Application;
use KTXC\Module\ModuleAutoloader;
// Capture Composer ClassLoader instance
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
// Determine project root (one level up from this file)
$projectRoot = dirname(__DIR__);
// Create and run application
$app = new Application($projectRoot);
// Store composer loader for compatibility
Application::setComposerLoader($composerLoader);
// Register custom module autoloader for lazy loading
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
$moduleAutoloader->register();
$app->run();

20
core/src/App.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { onMounted } from 'vue';
import { useTheme } from 'vuetify';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
const theme = useTheme();
const layoutStore = useLayoutStore();
// Apply saved theme on mount
onMounted(() => {
if (layoutStore.theme) {
theme.global.name.value = layoutStore.theme;
}
});
</script>

View File

@@ -0,0 +1,16 @@
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.64931 15.8644L6.96164 13.552L6.96405 13.5496H11.3143L9.58336 15.2806L9.13646 15.7275L7.36391 17.5L7.58344 17.7201L17.5137 27.6498L27.6634 17.5L25.8903 15.7275L25.7654 15.602L23.7131 13.5496H28.0633L28.0657 13.552L29.8781 15.3644L32.0137 17.5L17.5137 32L3.01367 17.5L4.64931 15.8644ZM17.5137 3L25.8921 11.3784H21.5419L17.5137 7.35024L13.4855 11.3784H9.13525L17.5137 3Z" fill="#096DD9"/>
<path d="M7.36453 17.4999L9.13708 15.7274L9.58398 15.2805L7.85366 13.5496H6.96467L6.96226 13.552L4.64993 15.8643L6.86938 18.0729L7.36453 17.4999Z" fill="url(#paint0_linear_112117_33940)"/>
<path d="M25.8911 15.7274L27.6643 17.4999L27.4888 17.6754L27.4894 17.676L29.8789 15.3643L28.0666 13.552L28.0641 13.5496H27.888L25.7663 15.6019L25.8911 15.7274Z" fill="url(#paint1_linear_112117_33940)"/>
<path d="M6.95946 13.5496L6.96187 13.552L9.13669 15.7274L17.5139 24.104L28.0684 13.5496H6.95946Z" fill="#1890FF"/>
<defs>
<linearGradient id="paint0_linear_112117_33940" x1="8.63954" y1="14.0887" x2="5.58137" y2="17.1469" gradientUnits="userSpaceOnUse">
<stop stop-color="#023B95"/>
<stop offset="0.9637" stop-color="#096CD9" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_112117_33940" x1="26.282" y1="14.1278" x2="28.7548" y2="16.9379" gradientUnits="userSpaceOnUse">
<stop stop-color="#023B95"/>
<stop offset="1" stop-color="#096DD9" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show More