Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/public/
/static/
# Backend development
/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
logs
*.log
*.log*
# Runtime
/modules/
/storage/
/var/

143
.stubs/mongodb.stub.php Normal file
View File

@@ -0,0 +1,143 @@
<?php
/**
* Stub file for MongoDB extension types
* This file provides type information for the PHP language server
* It should never be executed - it's only for IDE/static analysis
*/
namespace MongoDB\BSON {
/**
* BSON type for the "ObjectId" type
*/
class ObjectId implements \Stringable, \JsonSerializable, \MongoDB\BSON\Type
{
/**
* Construct a new ObjectId
* @param string|null $id A 24-character hexadecimal string. If not provided, the driver will generate an ObjectId.
*/
final public function __construct(?string $id = null) {}
/**
* Returns the hexadecimal representation of this ObjectId
*/
final public function __toString(): string {}
/**
* Returns the timestamp component of this ObjectId
*/
final public function getTimestamp(): int {}
/**
* Returns a representation that can be converted to JSON
*/
final public function jsonSerialize(): mixed {}
/**
* Checks if a value is a valid ObjectId
*/
final public static function isValid(string $id): bool {}
}
/**
* BSON type for the "UTCDateTime" type
*/
class UTCDateTime implements \JsonSerializable, \MongoDB\BSON\Type
{
/**
* Construct a new UTCDateTime
* @param int|\DateTimeInterface|null $milliseconds Number of milliseconds since the Unix epoch (1970-01-01 00:00:00 UTC)
*/
final public function __construct(int|\DateTimeInterface|null $milliseconds = null) {}
/**
* Returns the string representation of this UTCDateTime
*/
final public function __toString(): string {}
/**
* Returns the DateTime representation of this UTCDateTime
*/
final public function toDateTime(): \DateTime {}
/**
* Returns a representation that can be converted to JSON
*/
final public function jsonSerialize(): mixed {}
}
/**
* This interface is implemented by BSON types
*/
interface Type {}
}
namespace MongoDB\Driver {
/**
* The MongoDB\Driver\CursorInterface interface
*/
interface CursorInterface extends \Traversable
{
/**
* Returns the MongoDB\Driver\CursorId associated with this cursor
*/
public function getId(): CursorId;
/**
* Returns the MongoDB\Driver\Server associated with this cursor
*/
public function getServer(): Server;
/**
* Checks if the cursor may have additional results
*/
public function isDead(): bool;
/**
* Sets a type map to use for BSON unserialization
*/
public function setTypeMap(array $typemap): void;
/**
* Returns an array containing all results for this cursor
*/
public function toArray(): array;
}
/**
* The MongoDB\Driver\Cursor class
*/
final class Cursor implements CursorInterface, \Iterator
{
private function __construct() {}
public function current(): array|object|null {}
public function getId(): CursorId {}
public function getServer(): Server {}
public function isDead(): bool {}
public function key(): ?int {}
public function next(): void {}
public function rewind(): void {}
public function setTypeMap(array $typemap): void {}
public function toArray(): array {}
public function valid(): bool {}
}
/**
* The MongoDB\Driver\CursorId class
*/
final class CursorId
{
private function __construct() {}
public function __toString(): string {}
}
/**
* The MongoDB\Driver\Server class
*/
final class Server
{
private function __construct() {}
}
}

0
LICENSE Normal file
View File

17
bin/console Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
/**
* Console entry point - Symfony console has been removed.
* Add your own CLI commands here or integrate an alternative CLI library.
*/
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
require_once dirname(__DIR__).'/vendor/autoload.php';
echo "Console commands not available. Symfony console has been removed.\n";
echo "To add CLI functionality, integrate an alternative CLI library.\n";
exit(0);

4
bin/phpunit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

42
composer.json Normal file
View File

@@ -0,0 +1,42 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"mongodb/mongodb": "^2.1",
"php-di/php-di": "*",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"KTXC\\": "core/lib/",
"KTXF\\": "shared/lib/"
}
},
"autoload-dev": {
"psr-4": {
"KTXT\\": "tests/php/"
}
},
"scripts": {
"post-install-cmd": [
],
"post-update-cmd": [
],
"test": "phpunit --colors=always --testdox"
}
}

2470
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

49
config/system.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
return [
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
'database' => 'ktrix',
// optional driver options
'options' => [],
'driverOptions' => [],
],
/**
* Cache Configuration
*
* Set the cache store classes for different cache types.
* Uncomment and adjust the class names as needed.
*
* Available Cache Stores:
* - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc.
* - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc.
* - Blob Cache: Large binary objects storage.
*
* Predefined cache types:
* file - File-based cache store
* redis - Redis-based cache store
* memcached - Memcached-based cache store
*/
//'cache.ephemeral' => 'file',
//'cache.persistent' => 'file',
//'cache.blob' => 'file',
// Security Configuration
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
// Application Configuration
'app' => [
'environment' => 'dev',
'debug' => true,
'name' => 'Ktrix',
],
// Domain Configuration
//'domain' => [
// 'default' => 'ktrix',
//],
];

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,145 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\FileResponse;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\RedirectResponse;
use KTXC\Server;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract
{
public function __construct(
private readonly SecurityService $securityService,
private readonly SessionTenant $tenant,
private readonly SessionIdentity $identity,
) {}
#[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(
Server::runtimeRootLocation() . '/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(
Server::runtimeRootLocation() . '/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(
Server::runtimeRootLocation() . '/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(
Server::runtimeRootLocation() . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
// User is not authenticated - serve the public app
$response = new FileResponse(
Server::runtimeRootLocation() . '/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,43 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Module\ModuleManager;
use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly ModuleManager $moduleManager,
) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(): JsonResponse {
$configuration = [];
// modules
$configuration['modules'] = [];
foreach ($this->moduleManager->list() as $module) {
if (!method_exists($module, 'bootUi')) {
continue;
}
$configuration['modules'][$module->handle()] = $module->bootUi();
}
// tenant
$configuration['tenant'] = [
'id' => $this->tenant->identifier(),
'domain' => $this->tenant->domain(),
'label' => $this->tenant->label(),
];
return new JsonResponse($configuration);
}
}

View File

@@ -0,0 +1,58 @@
<?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'])]
public function index(): JsonResponse
{
$modules = $this->moduleManager->list(false);
return new JsonResponse(['modules' => $modules]);
}
#[AuthenticatedRoute('/modules/manage', name: 'modules.manage', methods: ['POST'])]
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,58 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserService;
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 UserService $userService
) {}
/**
* retrieve user settings
*
* @param array $settings list of settings to retrieve
*
* @example request body:
* {
* "settings": ["key1", "key2"]
* }
*/
#[AuthenticatedRoute('/user/settings/read', name: 'user.settings.read', methods: ['PUT', 'PATCH'])]
public function read(array $settings = []): JsonResponse
{
// authorize request
$tenantId = $this->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
return $this->userService->fetchSettings($tenantId, $userId, $settings);
}
/**
* store user settings
*
* @param array $settings key-value pairs of settings to store
*
* @example request body:
* {
* "key1": "value1",
* "key2": "value2"
* }
*/
#[AuthenticatedRoute('/user/settings/write', name: 'user.settings.write', methods: ['PUT', 'PATCH'])]
public function write(array $settings): JsonResponse
{
return new JsonResponse(['status' => 'not_implemented'], JsonResponse::HTTP_NOT_IMPLEMENTED);
}
}

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

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

421
core/lib/Kernel.php Normal file
View File

@@ -0,0 +1,421 @@
<?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\JsonResponse;
use KTXC\Http\Response\RedirectResponse;
use KTXC\Http\Response\Response;
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 KTXC\Routing\Router;
use KTXC\Routing\Route;
use KTXC\Service\SecurityService;
use KTXC\Service\FirewallService;
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;
use KTXM\MailManager\Queue\MailQueue;
use KTXM\MailManager\Queue\MailQueueFile;
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;
private string $projectDir;
public function __construct(
protected string $environment = 'prod',
protected bool $debug = false,
) {
if (!$environment) {
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
}
}
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;
}
$container = $this->initializeContainer();
$this->container = $container;
$this->initialized = true;
}
public function boot(): void
{
if (!$this->initialized) {
$this->initialize();
}
if (!$this->booted) {
/** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot();
$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();
}
/** @var SessionTenant $sessionTenant */
$sessionTenant = $this->container->get(SessionTenant::class);
$sessionTenant->configure($request->getHost());
if (!$sessionTenant->configured() && !$sessionTenant->enabled()) {
return new Response(Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED);
}
/** @var FirewallService $firewall */
$firewall = $this->container->get(FirewallService::class);
if (!$firewall->authorized($request)) {
return new Response(Response::$statusTexts[Response::HTTP_FORBIDDEN], Response::HTTP_FORBIDDEN);
}
/** @var Router $router */
$router = $this->container->get(Router::class);
if ($router) {
$match = $router->match($request);
if ($match instanceof Route) {
/** @var SecurityService $securityService */
$securityService = $this->container->get(SecurityService::class);
$identity = $securityService->authenticate($request);
if ($match->authenticated && $identity === null) {
return new Response(Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED);
}
if ($identity) {
/** @var SessionIdentity $sessionIdentity */
$sessionIdentity = $this->container->get(SessionIdentity::class);
$sessionIdentity->initialize($identity, true);
}
$response = $router->dispatch($match, $request);
if ($response instanceof Response) {
return $response;
}
}
}
return new Response(Response::$statusTexts[Response::HTTP_NOT_FOUND], Response::HTTP_NOT_FOUND);
}
/**
* 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
{
// Returns $this->getCacheDir() for backward compatibility
return $this->getCacheDir();
}
public function getLogDir(): string
{
return $this->folderRoot().'/var/log';
}
public function getCharset(): string
{
return 'UTF-8';
}
/**
* Gets a new container builder instance used to build the service container.
*/
protected function containerBuilder(): Builder
{
return new Builder(Container::class);
}
/**
* 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 = $this->containerBuilder();
$builder->useAutowiring(true);
$builder->useAttributes(true);
$builder->addDefinitions($this->parameters());
$this->configureContainer($builder);
return $builder->build();
}
protected function configureContainer(Builder $builder): void
{
$builder->addDefinitions($this->getConfigDir() . '/system.php');
// Service definitions
$logDir = $this->getLogDir();
$projectDir = $this->folderRoot();
$builder->addDefinitions([
LoggerInterface::class => function() use ($logDir) {
return new FileLogger($logDir);
},
// 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;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace KTXC\Module;
/**
* 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);
}
}
$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,464 @@
<?php
namespace KTXC\Module;
use Exception;
use KTXC\Module\Store\ModuleStore;
use KTXC\Module\Store\ModuleEntry;
use KTXC\Server;
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
) {
// Initialize server root path
$this->serverRoot = Server::runtimeRootLocation();
}
/**
* 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();
// 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);
}
/**
* 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;
}
/**
* 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(),
]);
}
}
}
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
{
// 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
$container = Server::runtimeContainer();
$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 ($container->has($typeName)) {
$args[] = $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;
}
}
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;
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace KTXC\Module;
use JsonSerializable;
use KTXC\Module\Store\ModuleEntry;
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';
}
// ===== 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 bootUi(): array | null
{
return $this->instance?->bootUi() ?? 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]);
}
}

253
core/lib/Resolver.php Normal file
View File

@@ -0,0 +1,253 @@
<?php
namespace Ktrix;
use ReflectionClass;
use ReflectionParameter;
use KTXC\Server;
/**
* Dynamic Class Resolver
* Resolves and instantiates classes with their dependencies dynamically
*/
class Resolver
{
private array $instanceCache = [];
private array $contextInstances = [];
private static array $globalContext = [];
/**
* Set a global context instance that will be used for all resolutions
*/
public static function setGlobalContext(string $className, object $instance): void
{
self::$globalContext[$className] = $instance;
}
/**
* Clear global context
*/
public static function clearGlobalContext(): void
{
self::$globalContext = [];
}
/**
* Resolve and instantiate a class with its dependencies
*/
public function resolve(string $className): object
{
return $this->resolveWithContext($className, []);
}
/**
* Resolve and instantiate a class with specific context instances
*/
public function resolveWithContext(string $className, array $contextInstances = []): object
{
// Store context instances temporarily
$originalContext = $this->contextInstances;
$this->contextInstances = array_merge($this->contextInstances, $contextInstances);
try {
// Check cache first (but not when we have context overrides)
if (empty($contextInstances) && isset($this->instanceCache[$className])) {
return $this->instanceCache[$className];
}
// Check if class exists
if (!class_exists($className)) {
throw new \InvalidArgumentException("Class {$className} does not exist");
}
$reflectionClass = new ReflectionClass($className);
// If class cannot be instantiated
if (!$reflectionClass->isInstantiable()) {
throw new \InvalidArgumentException("Class {$className} is not instantiable");
}
$constructor = $reflectionClass->getConstructor();
// If no constructor, just instantiate
if ($constructor === null) {
$instance = new $className();
if (empty($contextInstances)) {
$this->instanceCache[$className] = $instance;
}
return $instance;
}
// Resolve constructor dependencies
$dependencies = [];
foreach ($constructor->getParameters() as $parameter) {
$dependencies[] = $this->resolveParameter($parameter);
}
// Create instance with resolved dependencies
$instance = $reflectionClass->newInstanceArgs($dependencies);
// Only cache if no context overrides
if (empty($contextInstances)) {
$this->instanceCache[$className] = $instance;
}
return $instance;
} finally {
// Restore original context
$this->contextInstances = $originalContext;
}
}
/**
* Resolve a single parameter dependency
*/
private function resolveParameter(ReflectionParameter $parameter): mixed
{
$type = $parameter->getType();
// If no type hint, check if it has a default value
if ($type === null) {
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
throw new \InvalidArgumentException("Cannot resolve parameter {$parameter->getName()} without type hint");
}
// Handle union types (PHP 8+)
if ($type instanceof \ReflectionUnionType) {
throw new \InvalidArgumentException("Union types are not supported for parameter {$parameter->getName()}");
}
$typeName = $type->getName();
// Check if we have a context instance for this type
if (isset($this->contextInstances[$typeName])) {
return $this->contextInstances[$typeName];
}
// Check global context
if (isset(self::$globalContext[$typeName])) {
return self::$globalContext[$typeName];
}
// Handle built-in types
if ($type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
throw new \InvalidArgumentException("Cannot resolve built-in type {$typeName} for parameter {$parameter->getName()}");
}
// Always try to get from container first for all non-builtin types
try {
$container = Server::getContainer();
if ($container->has($typeName)) {
return $container->get($typeName);
}
} catch (\Exception $e) {
// Fall through to manual resolution
}
// Only try manual resolution for classes in our namespace
if (strpos($typeName, 'KTXC\\') === 0) {
try {
return $this->resolve($typeName);
} catch (\Exception $e) {
// If still can't resolve and has default value, use it
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
// If parameter is nullable, return null
if ($parameter->allowsNull()) {
return null;
}
throw new \InvalidArgumentException("Cannot resolve dependency {$typeName} for parameter {$parameter->getName()}: " . $e->getMessage());
}
}
// For non-Ktrix classes that aren't in the container, fail gracefully
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
if ($parameter->allowsNull()) {
return null;
}
throw new \InvalidArgumentException("Cannot resolve external dependency {$typeName} for parameter {$parameter->getName()}. This service should be registered in the container.");
}
/**
* Resolve and instantiate a class with automatic context detection
*/
public function resolveWithAutoContext(string $className, object $contextSource = null): object
{
$contextInstances = [];
if ($contextSource !== null) {
// Use reflection to get all properties of the context source
$reflection = new ReflectionClass($contextSource);
$properties = $reflection->getProperties();
foreach ($properties as $property) {
$property->setAccessible(true);
$value = $property->getValue($contextSource);
if (is_object($value)) {
$contextInstances[get_class($value)] = $value;
}
}
}
return $this->resolveWithContext($className, $contextInstances);
}
/**
* Smart resolve - automatically finds dependencies from the call stack
*/
public function smartResolve(string $className): object
{
$contextInstances = [];
// Get the call stack to find potential context sources
$backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 10);
foreach ($backtrace as $frame) {
if (isset($frame['object']) && is_object($frame['object'])) {
$sourceObject = $frame['object'];
$reflection = new ReflectionClass($sourceObject);
$properties = $reflection->getProperties();
foreach ($properties as $property) {
$property->setAccessible(true);
$value = $property->getValue($sourceObject);
if (is_object($value)) {
$contextInstances[get_class($value)] = $value;
}
}
}
}
return $this->resolveWithContext($className, $contextInstances);
}
/**
* Clear the instance cache
*/
public function clearCache(): void
{
$this->instanceCache = [];
}
/**
* Check if a class is cached
*/
public function isCached(string $className): bool
{
return isset($this->instanceCache[$className]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace KTXC\Resource;
use KTXC\Server;
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(
) {}
/**
* 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 = Server::runtimeContainer()->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,29 @@
<?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 function withParams(array $params): self
{
$clone = clone $this;
$clone->params = $params;
return $clone;
}
}

248
core/lib/Routing/Router.php Normal file
View File

@@ -0,0 +1,248 @@
<?php
namespace KTXC\Routing;
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 KTXC\Server;
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
)
{
$this->container = Server::runtimeContainer();
$this->cacheFile = Server::runtimeRootLocation() . '/var/cache/routes.cache.php';
}
private function initialize(): void
{
// load cached routes in production
if (Server::environment() === Server::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(Server::runtimeRootLocation() . '/core/lib/Controllers/*.php') as $file) {
$this->extract($file);
}
// load module controllers
foreach ($this->moduleManager->list(true, true) as $module) {
$path = Server::runtimeModuleLocation() . '/' . $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(),
);
}
}
}
} 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;
//try {
// 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;
//} catch (\Throwable $e) {
// $this->logger->error('Route dispatch failed', [
// 'controller' => $routeControllerName,
// 'method' => $routeControllerMethod,
// 'error' => $e->getMessage(),
// ]);
// throw $e;
//}
}
}

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,793 @@
<?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\UserService;
use KTXC\Service\UserProvisioningService;
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 UserService $userService,
private readonly UserProvisioningService $provisioningService,
) {
$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
);
}
// Find or provision user from external identity
$providerConfig = $this->getProviderConfig($method);
$user = $this->findOrProvisionUser($method, $result->identity, $providerConfig);
if ($user === null) {
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found and auto-provisioning is disabled',
401
);
}
// 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);
}
}

183
core/lib/Server.php Normal file
View File

@@ -0,0 +1,183 @@
<?php
namespace KTXC;
use ErrorException;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Injection\Container;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Provides static access to the server container
*/
class Server
{
public const ENVIRONMENT_DEV = 'dev';
public const ENVIRONMENT_PROD = 'prod';
protected static $kernel;
public static function run() {
// Set up global error handler before anything else
self::setupErrorHandlers();
try {
self::$kernel = new Kernel(self::ENVIRONMENT_DEV, true);
$request = Request::createFromGlobals();
$response = self::$kernel->handle($request);
if ($response instanceof Response) {
$response->send();
}
} catch (\Throwable $e) {
self::logException($e);
$content = self::debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
public static function environment(): string {
return self::$kernel->environment();
}
public static function debug(): bool {
return self::$kernel->debug();
}
public static function runtimeKernel(): Kernel {
return self::$kernel;
}
public static function runtimeContainer(): Container {
return self::$kernel->container();
}
public static function runtimeRootLocation(): string {
return self::$kernel->folderRoot();
}
public static function runtimeModuleLocation(): string {
return self::$kernel->folderRoot() . '/modules';
}
/**
* Set up global error and exception handlers
*/
protected static function setupErrorHandlers(): 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
);
self::logError($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) {
self::logException($exception);
if (self::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']
);
self::logError($message, $error);
if (self::debug()) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
}
});
}
/**
* Log an error message
*/
protected static function logError(string $message, array $context = []): void {
try {
if (self::$kernel && self::$kernel->container()->has(LoggerInterface::class)) {
$logger = self::$kernel->container()->get(LoggerInterface::class);
$logger->error($message, $context);
} else {
// Fallback to error_log if logger not available
error_log($message . ' ' . json_encode($context));
}
} catch (Throwable $e) {
// Last resort fallback
error_log('Error logging failed: ' . $e->getMessage());
error_log($message . ' ' . json_encode($context));
}
}
/**
* Log an exception
*/
protected static function logException(Throwable $exception): void {
try {
if (self::$kernel && self::$kernel->container()->has(LoggerInterface::class)) {
$logger = self::$kernel->container()->get(LoggerInterface::class);
$logger->error('Exception caught: ' . $exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
]);
} else {
// Fallback to error_log if logger not available
error_log('Exception: ' . $exception->getMessage() . ' in ' . $exception->getFile() . ':' . $exception->getLine());
error_log($exception->getTraceAsString());
}
} catch (Throwable $e) {
// Last resort fallback
error_log('Exception logging failed: ' . $e->getMessage());
error_log('Original exception: ' . $exception->getMessage());
}
}
}

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,202 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
/**
* 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 TokenService $tokenService,
private readonly UserService $userService,
private readonly SessionTenant $sessionTenant
) {
$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->authenticateBasicHeader($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 authenticateBasicHeader(string $identity, string $credentials): ?User
{
// For Basic auth headers, we need to validate against the provider
// This is a simplified flow for API access
$provider = $this->providerRegistry->resolve('default');
if ($provider === null) {
return null;
}
$result = $provider->authenticate($identity, $credentials);
if (!$result->isSuccess()) {
return null;
}
return $this->getUserByIdentity($identity);
}
// =========================================================================
// Token Operations (delegated to AuthenticationManager for new flows)
// These are kept for backwards compatibility during transition
// =========================================================================
/**
* @deprecated Use AuthenticationManager::createTokens() instead
*/
public function createAccessToken(array $payload): string
{
return $this->tokenService->createToken($payload, $this->securityCode, 900);
}
/**
* @deprecated Use AuthenticationManager::createTokens() instead
*/
public function createRefreshToken(array $payload): string
{
$refreshPayload = [
'tenant' => $payload['tenant'] ?? null,
'identifier' => $payload['identifier'],
'identity' => $payload['identity'],
'type' => 'refresh'
];
return $this->tokenService->createToken($refreshPayload, $this->securityCode, 604800);
}
/**
* @deprecated Use AuthenticationManager::refreshAccessToken() instead
*/
public function validateRefreshToken(string $refreshToken): ?User
{
$payload = $this->tokenService->validateToken($refreshToken, $this->securityCode);
if (!$payload) {
return null;
}
if (!isset($payload['type']) || $payload['type'] !== 'refresh') {
return null;
}
$identifier = $payload['identifier'] ?? null;
if (!$identifier || $this->providerRegistry->validateUser($identifier) === false) {
return null;
}
$user = new User();
$user->populate([
'identifier' => $payload['identifier'],
'identity' => $payload['identity'],
'tenant' => $payload['tenant'] ?? null,
], 'jwt');
return $user;
}
/**
* @deprecated Use AuthenticationManager::logout() instead
*/
public function logout(?string $jti = null, ?int $exp = null): void
{
if ($jti !== null) {
$expiresAt = $exp ?? (time() + 86400);
$this->tokenService->blacklist($jti, $expiresAt);
}
}
/**
* @deprecated Use AuthenticationManager::logoutAll() instead
*/
public function logoutAllDevices(string $identity): void
{
$this->tokenService->blacklistUserTokensBefore($identity, time());
}
/**
* 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,137 @@
<?php
namespace KTXC\Service;
use KTXC\Identity\Provider\DefaultIdentityProvider;
use KTXC\Models\Identity\User;
use KTXC\Server;
use KTXC\SessionTenant;
/**
* User Manager Service
* Manages authentication providers and user operations across domains
*/
class UserManagerService
{
private array $availableIdentityProviders = [];
private array $cachedIdentityProviders = [];
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserService $userService
) {
// Register the default identity provider
$this->providerRegister('default', DefaultIdentityProvider::class);
}
/**
* Register an authentication provider
*/
public function providerRegister(string $identifier, string $class): void
{
$this->availableIdentityProviders[$identifier] = $class;
}
public function providerList(?array $filter = null): array
{
$requestedProviders = $filter ? $filter : array_keys($this->availableIdentityProviders);
$result = [];
foreach ($requestedProviders as $identifier) {
// Check if provider is available
if (!isset($this->availableIdentityProviders[$identifier])) {
continue;
}
// Check cache first
if (isset($this->cachedIdentityProviders[$identifier])) {
$result[$identifier] = $this->cachedIdentityProviders[$identifier];
} else {
// Instantiate the provider and cache it
$providerClass = $this->availableIdentityProviders[$identifier];
try {
// Server::get automatically detects context from calling object
$providerInstance = Server::runtimeContainer()->get($providerClass);
// Cache the instance
$this->cachedIdentityProviders[$identifier] = $providerInstance;
$result[$identifier] = $providerInstance;
} catch (\Exception $e) {
// Skip providers that can't be resolved
error_log("Failed to resolve identity provider {$providerClass}: " . $e->getMessage());
continue;
}
}
}
return $result;
}
/**
* Authenticate user against enabled providers
*/
public function authenticate(string $identity, string $credential): User | null
{
// validate identity and credential
if (empty($identity) || empty($credential)) {
return null;
}
// retrieve user by identity
$user = $this->userService->fetchByIdentity($identity);
// determine if user has logged in before
if (!$user) {
return null;
}
// determine if user has a identity provider assigned
if ($user->getProvider() === null) {
return null;
}
$authenticated = $this->authenticateExtant($user->getProvider(), $identity, $credential);
if ($authenticated) {
return $user;
}
return null;
}
public function authenticateExtant(string $provider, string $identity, string $credential): bool {
// determine if provider is enabled
$providers = $this->providerList([$provider]);
if (empty($providers)) {
return false;
}
// Get the first (and should be only) provider
$provider = reset($providers);
// authenticate user against provider
$user = $provider->authenticate($identity, $credential);
return $user;
}
public function validate(string $identifier): Bool
{
$data = $this->userService->fetchByIdentifier($identifier);
if (!$data) {
return false;
}
if ($data['enabled'] !== true) {
return false;
}
if ($data['tid'] !== $this->tenant->identifier()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
use KTXC\Stores\ExternalIdentityStore;
use KTXF\Utile\UUID;
/**
* User Provisioning Service
* Handles JIT (Just-In-Time) user provisioning from external identity providers
* and profile synchronization on login
*/
class UserProvisioningService
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserStore $userStore,
private readonly ExternalIdentityStore $externalIdentityStore
) { }
/**
* Provision a new user from external provider attributes
*
* @param string $providerId Provider identifier
* @param array $attributes User attributes from provider
* @param array $providerConfig Provider configuration including attribute_map and default_roles
* @return User|null The provisioned user or null on failure
*/
public function provisionUser(string $providerId, array $attributes, array $providerConfig): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Map attributes to user fields
$mappedData = $this->mapAttributes($attributes, $providerConfig['attribute_map'] ?? []);
// Validate required fields
$identity = $mappedData['identity'] ?? $attributes['identity'] ?? $attributes['email'] ?? null;
if (!$identity) {
return null;
}
// Generate user ID
$userId = UUID::v4();
// Build user data
$userData = [
'tid' => $tenantId,
'uid' => $userId,
'identity' => $identity,
'label' => $mappedData['label'] ?? $attributes['label'] ?? $attributes['name'] ?? $identity,
'enabled' => true,
'provider' => $providerId,
'external_subject' => $attributes['external_subject'] ?? null,
'roles' => $providerConfig['default_roles'] ?? [],
'profile' => $mappedData['profile'] ?? [],
'settings' => [],
'initial_login' => time(),
'recent_login' => time(),
];
// Create the user
$createdUserId = $this->userStore->create($userData);
if (!$createdUserId) {
return null;
}
// Link external identity if we have an external subject
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
// Build and return User object
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Synchronize user profile with attributes from provider
* Called on each login to keep profile data up to date
*
* @param User $user The existing user
* @param array $attributes Attributes from provider
* @param array $attributeMap Attribute mapping configuration
* @return bool Whether sync was successful
*/
public function syncProfile(User $user, array $attributes, array $attributeMap = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
// Map attributes
$mappedData = $this->mapAttributes($attributes, $attributeMap);
// Update profile fields if we have mapped profile data
if (!empty($mappedData['profile'])) {
$this->userStore->updateProfile($tenantId, $userId, $mappedData['profile']);
}
// Update label if provided and different
if (!empty($mappedData['label']) && $mappedData['label'] !== $user->getLabel()) {
$this->userStore->updateLabel($tenantId, $userId, $mappedData['label']);
}
// Always update last login
$this->userStore->updateLastLogin($tenantId, $userId);
// Update external identity attributes if applicable
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->updateLastLogin(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject']
);
$this->externalIdentityStore->updateAttributes(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
return true;
}
/**
* Link an external identity to an existing user
*
* @param User $user The user to link
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether linking was successful
*/
public function linkExternalIdentity(User $user, string $providerId, string $externalSubject, array $attributes = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
return $this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$externalSubject,
$attributes
);
}
/**
* Find user by external identity
*
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @return User|null The user or null if not found
*/
public function findByExternalIdentity(string $providerId, string $externalSubject): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Look up in external identities table
$externalIdentity = $this->externalIdentityStore->findByExternalSubject(
$tenantId,
$providerId,
$externalSubject
);
if (!$externalIdentity) {
return null;
}
// Fetch the linked user
$userData = $this->userStore->fetchByIdentifier($tenantId, $externalIdentity['uid']);
if (!$userData) {
return null;
}
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Map provider attributes to user fields using attribute map
*
* @param array $attributes Raw attributes from provider
* @param array $attributeMap Mapping configuration {source_attr: target_field}
* @return array Mapped data with 'identity', 'label', 'profile' keys
*/
protected function mapAttributes(array $attributes, array $attributeMap): array
{
$result = [
'identity' => null,
'label' => null,
'profile' => [],
];
foreach ($attributeMap as $sourceAttr => $targetField) {
// Get source value (supports nested attributes with dot notation)
$value = $this->getNestedValue($attributes, $sourceAttr);
if ($value === null) {
continue;
}
// Set target value (supports nested targets with dot notation)
if ($targetField === 'identity') {
$result['identity'] = $value;
} elseif ($targetField === 'label') {
$result['label'] = $value;
} elseif (str_starts_with($targetField, 'profile.')) {
$profileField = substr($targetField, 8);
$result['profile'][$profileField] = $value;
}
}
return $result;
}
/**
* Get nested value from array using dot notation
*
* @param array $array Source array
* @param string $key Key with optional dot notation (e.g., 'user.email')
* @return mixed|null Value or null if not found
*/
protected function getNestedValue(array $array, string $key): mixed
{
$keys = explode('.', $key);
$value = $array;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return null;
}
$value = $value[$k];
}
return $value;
}
/**
* Check if auto-provisioning is enabled for a provider
*
* @param string $providerId Provider identifier
* @return bool
*/
public function isAutoProvisioningEnabled(string $providerId): bool
{
$config = $this->tenant->identityProviderConfig($providerId);
return ($config['provisioning'] ?? 'manual') === 'auto';
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
class UserService
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserStore $userStore
) {
}
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 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);
}
}

View File

@@ -0,0 +1,64 @@
<?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
{
$permissions = $this->identityData?->getPermissions() ?? [];
$permissions[] = 'ROLE_USER';
return array_unique($permissions);
}
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions());
}
}

122
core/lib/SessionTenant.php Normal file
View File

@@ -0,0 +1,122 @@
<?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;
/**
* Configure the tenant information
* This method is called by the SecurityMiddleware after validation
*/
public function configure(string $domain): void
{
if ($this->configured) {
return;
}
$service = Server::runtimeContainer()->get(TenantService::class);
$tenant = $service->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,224 @@
<?php
declare(strict_types=1);
namespace KTXC\Stores;
use KTXC\Db\DataStore;
/**
* External Identity Store
* Maps external identity provider subjects to local users
*
* Collection: external_identities
* Schema: {
* tid: string, // Tenant identifier
* uid: string, // Local user identifier
* provider: string, // Provider identifier (e.g., 'oidc', 'saml')
* external_subject: string, // External subject identifier (e.g., OIDC sub, SAML NameID)
* attributes: object, // Cached attributes from provider
* linked_at: int, // Timestamp when identity was linked
* last_login: int // Last login via this external identity
* }
*/
class ExternalIdentityStore
{
protected const COLLECTION_NAME = 'external_identities';
public function __construct(protected DataStore $store)
{ }
/**
* Find external identity by provider and external subject
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return array|null External identity record or null
*/
public function findByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
/**
* Find all external identities for a user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @return array<array> List of external identity records
*/
public function findByUser(string $tenant, string $userId): array
{
$cursor = $this->store->selectCollection(self::COLLECTION_NAME)->find([
'tid' => $tenant,
'uid' => $userId
]);
$result = [];
foreach ($cursor as $entry) {
$result[] = (array)$entry;
}
return $result;
}
/**
* Find external identity for a user from a specific provider
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @return array|null External identity record or null
*/
public function findByUserAndProvider(string $tenant, string $userId, string $provider): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenant,
'uid' => $userId,
'provider' => $provider
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
/**
* Link an external identity to a local user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether the operation was successful
*/
public function linkIdentity(
string $tenant,
string $userId,
string $provider,
string $externalSubject,
array $attributes = []
): bool {
$now = time();
// Use upsert to handle both create and update
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
[
'$set' => [
'uid' => $userId,
'attributes' => $attributes,
'last_login' => $now
],
'$setOnInsert' => [
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject,
'linked_at' => $now
]
],
['upsert' => true]
);
return $result->isAcknowledged();
}
/**
* Unlink an external identity from a user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @return bool Whether the operation was successful
*/
public function unlinkIdentity(string $tenant, string $userId, string $provider): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([
'tid' => $tenant,
'uid' => $userId,
'provider' => $provider
]);
return $result->isAcknowledged();
}
/**
* Update last login timestamp for an external identity
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return bool Whether the operation was successful
*/
public function updateLastLogin(string $tenant, string $provider, string $externalSubject): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
['$set' => ['last_login' => time()]]
);
return $result->isAcknowledged();
}
/**
* Update cached attributes for an external identity
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes New attributes to store
* @return bool Whether the operation was successful
*/
public function updateAttributes(string $tenant, string $provider, string $externalSubject, array $attributes): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
['$set' => ['attributes' => $attributes]]
);
return $result->isAcknowledged();
}
/**
* Delete all external identities for a user (used when deleting user)
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @return int Number of deleted records
*/
public function deleteAllForUser(string $tenant, string $userId): int
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteMany([
'tid' => $tenant,
'uid' => $userId
]);
return $result->getDeletedCount();
}
}

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,257 @@
<?php
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\Collection;
class UserStore
{
public function __construct(protected DataStore $store)
{ }
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('users')->aggregate($pipeline)->toArray()[0] ?? null;
if (!$entry) { return null; }
return (array)$entry;
}
public function fetchByIdentifier(string $tenant, string $identifier): array | null
{
$entry = $this->store->selectCollection('users')->findOne(['tid' => $tenant, 'uid' => $identifier]);
if (!$entry) { return null; }
return (array)$entry;
}
/**
* Fetch user settings from the embedded settings field in the user document
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $settings Optional array of specific setting keys to retrieve
* @return array|null Settings array or null if user not found
*/
public function fetchSettings(string $tenant, string $identifier, array $keys = []): array | null
{
$entry = $this->store->selectCollection('users')->findOne(
['tid' => $tenant, 'uid' => $identifier],
['projection' => ['settings' => 1]]
);
if (!$entry) {
return null;
}
$settings = (array)($entry['settings'] ?? []);
if (empty($keys)) {
return $settings;
}
// Filter to only requested keys
return array_filter(
$settings,
fn($key) => in_array($key, $keys),
ARRAY_FILTER_USE_KEY
);
}
/**
* Store/update user settings in the embedded settings field
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $settings Key-value pairs to set/update
* @return bool Whether the update was acknowledged
*/
public function storeSettings(string $tenant, string $identifier, array $settings): bool
{
// Build dot-notation update for each setting key
$setFields = [];
foreach ($settings as $key => $value) {
$setFields["settings.$key"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => $setFields]
);
return $result->isAcknowledged();
}
/**
* Remove specific settings from a user
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $keys Setting keys to remove
* @return bool Whether the update was acknowledged
*/
public function removeSettings(string $tenant, string $identifier, array $keys): bool
{
$unsetFields = [];
foreach ($keys as $key) {
$unsetFields["settings.$key"] = "";
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$unset' => $unsetFields]
);
return $result->isAcknowledged();
}
/**
* Create a new user
*
* @param array $data User data including tid, uid, identity, label, provider, etc.
* @return string|null The created user's UID or null on failure
*/
public function create(array $data): ?string
{
// Ensure required fields
if (empty($data['tid']) || empty($data['uid']) || empty($data['identity'])) {
return null;
}
// Set defaults
$data['enabled'] = $data['enabled'] ?? true;
$data['roles'] = $data['roles'] ?? [];
$data['profile'] = $data['profile'] ?? [];
$data['settings'] = $data['settings'] ?? [];
$data['initial_login'] = $data['initial_login'] ?? time();
$data['recent_login'] = $data['recent_login'] ?? time();
$result = $this->store->selectCollection('users')->insertOne($data);
if ($result->isAcknowledged()) {
return $data['uid'];
}
return null;
}
/**
* Update user profile fields
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $profile Profile data to update
* @return bool Whether the update was acknowledged
*/
public function updateProfile(string $tenant, string $identifier, array $profile): bool
{
$setFields = [];
foreach ($profile as $key => $value) {
$setFields["profile.$key"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => $setFields]
);
return $result->isAcknowledged();
}
/**
* Update user's last login timestamp
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @return bool Whether the update was acknowledged
*/
public function updateLastLogin(string $tenant, string $identifier): bool
{
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => ['recent_login' => time()]]
);
return $result->isAcknowledged();
}
/**
* Update user's label
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param string $label New label
* @return bool Whether the update was acknowledged
*/
public function updateLabel(string $tenant, string $identifier, string $label): bool
{
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => ['label' => $label]]
);
return $result->isAcknowledged();
}
/**
* Find user by external subject (for external identity providers)
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return array|null User data or null if not found
*/
public function fetchByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
{
$entry = $this->store->selectCollection('users')->findOne([
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
}

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

@@ -0,0 +1,12 @@
<?php
use KTXC\Server;
use KTXC\Module\ModuleAutoloader;
require_once __DIR__ . '../../vendor/autoload.php';
// Register custom module autoloader for lazy loading
$moduleAutoloader = new ModuleAutoloader(__DIR__ . '/../modules');
$moduleAutoloader->register();
Server::run();

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

@@ -0,0 +1,7 @@
<template>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router';
</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

View File

@@ -0,0 +1,353 @@
<svg shape-rendering="geometricPrecision" text-rendering="geometricPrecision" viewBox="0 0 532 475" xmlns="http://www.w3.org/2000/svg">
<path d="m266 474.5c146.63 0 265.5-60.442 265.5-135s-118.87-135-265.5-135-265.5 60.442-265.5 135 118.87 135 265.5 135z" fill="#fff"/>
<path d="M246.525,353.382l-54.356-31.019c-.888-.51-2.32-.499-3.208.011l-.566.322c-.888.51-.877,1.331.011,1.83l18.526,10.351-18.948,10.939-18.303-10.562c-.888-.51-2.32-.51-3.197,0l-.566.322c-.888.511-.888,1.343,0,1.853l55.277,31.896.599-.71.3.177l21.822-12.569.167.099.322-.377.466-.266-.167-.089l1.821-2.208Z" fill="#f5f5f5"/>
<path d="M51.2325,387.902L25.5411,373.079c-1.3881-.797-1.3881-2.092,0-2.89L297.759,213.098c1.388-.797,3.62-.797,5.009,0l25.691,14.823c1.388.797,1.388,2.092,0,2.89L56.2412,387.902c-1.3881.797-3.6206.797-5.0087,0Z" fill="#f5f5f5"/>
<path d="M154.674,361.659c6.593,3.706,6.659,9.841.144,13.691-6.505,3.849-17.127,3.96-23.72.255-6.594-3.706-6.66-9.841-.145-13.69c6.505-3.861,17.127-3.972,23.721-.256Z" fill="#f5f5f5"/>
<path d="M230.504,419.116L252.88,405.42c.827-.512.827-1.329,0-1.841l-22.376-13.696c-.826-.511-2.172-.511-3.008,0L205.12,403.579c-.827.512-.827,1.329,0,1.841l22.376,13.696c.836.512,2.182.512,3.008,0Z" fill="#f5f5f5"/>
<g transform="translate(1e-6 1e-6)">
<path d="M208.508,402.685l19.495-11.245c.365-.21.838-.319,1.303-.319.474,0,.947.1,1.312.319l19.486,11.245c.364.209.537.482.537.755v1.421c0,.273-.173.546-.537.756l-19.486,11.244c-.365.21-.838.319-1.312.319s-.948-.1-1.303-.319l-19.495-11.244c-.364-.21-.537-.483-.537-.756v-1.421c0-.264.173-.537.537-.755Z" fill="#fa8c16"/>
<path d="M207.971,403.44v1.421c0,.273.173.546.537.756l19.495,11.244c.365.21.838.319,1.303.319v-26.059c-.474,0-.948.1-1.303.319l-19.495,11.245c-.364.218-.537.491-.537.755Z" opacity=".1"/>
<path d="M229.307,391.121v26.05c.473,0,.947-.1,1.311-.319l19.486-11.245c.365-.209.538-.482.538-.755v-1.421c0-.273-.173-.546-.538-.755l-19.486-11.245c-.364-.201-.838-.31-1.311-.31Z" opacity=".3"/>
<path d="M230.618,415.45l19.495-11.245c.72-.419.72-1.092,0-1.511l-19.495-11.245c-.72-.419-1.895-.419-2.614,0l-19.495,11.245c-.72.419-.72,1.092,0,1.511l19.495,11.245c.719.419,1.894.419,2.614,0Z" fill="#fa8c16"/>
<path d="M230.618,415.45l19.495-11.245c.72-.419.72-1.092,0-1.511l-19.495-11.245c-.72-.419-1.895-.419-2.614,0l-19.495,11.245c-.72.419-.72,1.092,0,1.511l19.495,11.245c.719.419,1.894.419,2.614,0Z" fill="#fff" opacity=".5"/>
<path d="M229.307,391.13c.473,0,.947.101,1.311.31l19.495,11.245c.72.419.72,1.092,0,1.511l-19.495,11.245c-.364.21-.838.31-1.311.31v-24.621Z" opacity=".05"/>
<path d="M217.273,402.029l8.618-25.895c0-.009,0-.009.009-.018l.009-.028c.136-.364.446-.719.938-1.001c1.357-.783,3.553-.783,4.91,0c.492.282.802.637.939,1.001h.009l.009.028c0,0,0,0,0,.009l8.627,25.895c.792,2.249-.319,4.68-3.343,6.428-4.801,2.768-12.572,2.768-17.373,0-3.033-1.739-4.145-4.17-3.352-6.419Z" fill="#fa8c16"/>
<path d="M223.421,383.564l.009-.028l1.23-3.678c-.201.628-.018,1.284.537,1.848.21.21.456.41.766.583c1.849,1.065,4.846,1.065,6.695,0c.31-.182.565-.373.766-.583.564-.574.737-1.229.537-1.857l1.23,3.687.009.018c.2.62.109,1.257-.273,1.849-.292.455-.747.883-1.376,1.247-.446.255-.956.446-1.485.61-1.722.519-3.771.519-5.493,0-.528-.164-1.039-.355-1.485-.61-.629-.364-1.084-.792-1.376-1.247-.4-.592-.491-1.229-.291-1.839Z" fill="#fafafa"/>
<path d="M218.483,398.396l.009-.028l1.23-3.687c-.2.61-.219,1.238-.073,1.848.155.646.501,1.275,1.039,1.857.446.483,1.011.929,1.712,1.339c3.817,2.203,9.994,2.203,13.811,0c.701-.401,1.266-.856,1.712-1.339.547-.582.884-1.22,1.039-1.857.146-.61.137-1.238-.064-1.848l1.221,3.678.009.037c.2.61.246,1.238.137,1.857-.255,1.402-1.303,2.768-3.171,3.842-4.309,2.486-11.287,2.486-15.596,0-1.858-1.074-2.915-2.44-3.17-3.842-.091-.619-.055-1.247.155-1.857Z" fill="#fafafa"/>
<path d="M220.953,390.975l.009-.027l1.23-3.688c-.201.61-.155,1.248.118,1.849.31.682.911,1.32,1.822,1.857.018.009.037.027.055.036c2.833,1.63,7.424,1.63,10.248,0c.019-.009.037-.027.055-.036.911-.537,1.512-1.175,1.822-1.857.273-.61.319-1.239.118-1.849l1.23,3.679.009.027c.201.61.201,1.247,0,1.858-.218.646-.674,1.283-1.348,1.848-.291.246-.61.473-.984.692-.947.546-2.077.938-3.27,1.174-1.777.346-3.717.346-5.493,0-1.194-.236-2.314-.619-3.271-1.174-.373-.219-.701-.446-.984-.692-.674-.565-1.129-1.193-1.348-1.848-.219-.601-.219-1.23-.018-1.849Z" fill="#fafafa"/>
<path d="M229.307,374.504c.892,0,1.776.191,2.459.583.492.282.802.637.939,1.001h.009l.009.028c0,0,0,0,0,.009l8.627,25.895c.792,2.249-.319,4.68-3.343,6.428-2.396,1.384-5.539,2.076-8.682,2.076.027,0,.045-.009.073-.009c3.89-.619,6.513-4.334,5.967-8.24l-3.59-25.777-2.468-1.994Z" opacity=".1"/>
<path d="M231.766,377.928c1.357-.783,1.357-2.049,0-2.832s-3.553-.783-4.91,0-1.357,2.049,0,2.832c1.348.783,3.553.783,4.91,0Z" fill="#fa8c16"/>
<path d="M231.766,377.928c1.357-.783,1.357-2.049,0-2.832s-3.553-.783-4.91,0-1.357,2.049,0,2.832c1.348.783,3.553.783,4.91,0Z" fill="#fff" opacity=".5"/>
</g>
<path d="M418.987,376.681l20.202-11.66c.755-.433.755-1.132,0-1.564l-20.202-11.66c-.755-.433-1.965-.433-2.719,0l-20.202,11.66c-.755.432-.755,1.131,0,1.564l20.202,11.66c.754.433,1.975.433,2.719,0Z" fill="#f5f5f5"/>
<g transform="translate(1e-6)">
<path d="M398.486,361.438l17.937-10.351c.333-.2.766-.289,1.199-.289s.877.1,1.21.289l17.937,10.351c.333.199.499.443.499.699v1.309c0,.255-.166.499-.499.699l-17.937,10.35c-.333.2-.777.289-1.21.289s-.866-.1-1.199-.289l-17.937-10.35c-.333-.2-.5-.444-.5-.699v-1.309c.011-.256.167-.5.5-.699Z" fill="#fa8c16"/>
<path d="M397.998,362.137v1.309c0,.255.167.499.5.699l17.937,10.35c.333.2.766.289,1.199.289v-23.986c-.433,0-.866.1-1.199.289l-17.937,10.351c-.345.199-.5.443-.5.699Z" opacity=".1"/>
<path d="M417.633,350.798v23.986c.433,0,.877-.1,1.21-.289l17.937-10.35c.333-.2.499-.444.499-.699v-1.309c0-.256-.166-.5-.499-.699l-17.937-10.351c-.333-.2-.777-.289-1.21-.289Z" opacity=".3"/>
<path d="M418.843,373.186l17.938-10.35c.666-.389.666-1.01,0-1.387l-17.938-10.351c-.666-.388-1.742-.388-2.408,0l-17.937,10.351c-.666.388-.666,1.009,0,1.387l17.937,10.35c.655.378,1.742.378,2.408,0Z" fill="#fa8c16"/>
<path d="M418.843,373.186l17.938-10.35c.666-.389.666-1.01,0-1.387l-17.938-10.351c-.666-.388-1.742-.388-2.408,0l-17.937,10.351c-.666.388-.666,1.009,0,1.387l17.937,10.35c.655.378,1.742.378,2.408,0Z" fill="#fff" opacity=".5"/>
<path d="M417.633,350.798c.433,0,.877.1,1.21.289l17.937,10.351c.666.388.666,1.009,0,1.386l-17.937,10.351c-.333.189-.766.289-1.21.289v-22.666Z" opacity=".05"/>
<path d="M406.556,360.828l7.936-23.831v-.011l.011-.022c.123-.333.411-.666.866-.921c1.254-.721,3.275-.721,4.518,0c.455.267.744.588.866.921l.011.022c0,0,0,0,0,.011l7.936,23.842c.733,2.074-.289,4.315-3.075,5.913-4.417,2.552-11.577,2.552-15.994,0-2.775-1.609-3.797-3.85-3.075-5.924Z" fill="#fa8c16"/>
<path d="M412.217,343.831l.011-.022l1.132-3.384c-.189.577-.022,1.187.489,1.709.188.188.421.377.71.543c1.698.977,4.462.977,6.16,0c.289-.166.522-.344.711-.543.51-.522.677-1.132.488-1.709l1.132,3.395.011.022c.189.566.1,1.154-.244,1.709-.266.421-.688.81-1.265,1.142-.411.233-.877.411-1.366.566-1.587.477-3.474.477-5.061,0-.488-.144-.955-.321-1.365-.566-.577-.332-.999-.732-1.266-1.142-.377-.566-.466-1.154-.277-1.72Z" fill="#fafafa"/>
<path d="M407.666,357.488l.011-.022l1.132-3.395c-.189.566-.2,1.143-.067,1.709.145.588.456,1.176.955,1.708.411.444.932.855,1.576,1.232c3.508,2.03,9.202,2.03,12.709,0c.644-.377,1.166-.788,1.577-1.232.499-.543.81-1.12.954-1.708.133-.566.122-1.143-.066-1.698l1.132,3.384.011.033c.189.566.222,1.143.122,1.709-.233,1.287-1.199,2.552-2.919,3.539-3.963,2.285-10.39,2.285-14.352,0-1.71-.987-2.686-2.241-2.92-3.539-.077-.577-.044-1.154.145-1.72Z" fill="#fafafa"/>
<path d="M409.942,350.654l.011-.022l1.132-3.395c-.189.566-.144,1.143.111,1.709.288.621.843,1.22,1.676,1.708.022.011.033.022.055.034c2.609,1.508,6.827,1.508,9.435,0c.022-.012.034-.023.056-.034.832-.499,1.398-1.087,1.676-1.708.255-.555.288-1.143.111-1.709l1.132,3.395.011.022c.189.566.189,1.143,0,1.709-.2.599-.621,1.176-1.243,1.697-.266.222-.566.433-.91.633-.877.51-1.909.865-3.008,1.076-1.643.321-3.419.321-5.062,0-1.099-.211-2.131-.577-3.008-1.076-.344-.2-.644-.411-.91-.633-.622-.521-1.043-1.098-1.243-1.697-.211-.555-.211-1.143-.022-1.709Z" fill="#fafafa"/>
<path d="M417.633,335.5c.821,0,1.631.177,2.264.543.455.266.744.588.866.921l.011.022c0,0,0,0,0,.011l7.936,23.842c.733,2.074-.288,4.315-3.074,5.913-2.209,1.276-5.106,1.908-7.992,1.908.022,0,.044-.011.067-.011c3.585-.577,5.993-3.994,5.494-7.577l-3.297-23.731-2.275-1.841Z" opacity=".1"/>
<path d="M419.898,338.65c1.254-.721,1.254-1.886,0-2.607s-3.275-.721-4.518,0c-1.254.721-1.254,1.886,0,2.607c1.254.722,3.264.722,4.518,0Z" fill="#fa8c16"/>
<path d="M419.898,338.65c1.254-.721,1.254-1.886,0-2.607s-3.275-.721-4.518,0c-1.254.721-1.254,1.886,0,2.607c1.254.722,3.264.722,4.518,0Z" fill="#fff" opacity=".5"/>
</g>
<g transform="translate(4e-6 2e-6)">
<path d="m50.491 357.3s2.7195-13.291-0.4107-25.317c-3.1301-12.026-9.8788-21.09-17.837-24.396-7.9696-3.306-17.027 2.063-9.9565 10.073s20.89 18.35 21.744 39.995l6.4601-0.355z" fill="#52c41a"/>
<path d="m50.491 357.3s2.7195-13.291-0.4107-25.317c-3.1301-12.026-9.8788-21.09-17.837-24.396-7.9696-3.306-17.027 2.063-9.9565 10.073s20.89 18.35 21.744 39.995l6.4601-0.355z" opacity=".15"/>
<path d="M49.1816,353.194c.0333,0,.0666.011.0999,0c.2553-.023.444-.233.4218-.488-1.6983-24.053-15.24-38.275-22.1552-42.436-.2109-.133-.4995-.055-.6327.156-.1332.222-.0555.499.1554.632c6.782,4.072,20.0351,18.039,21.7223,41.714.0111.222.1776.388.3885.422Z" fill="#fff"/>
</g>
<g transform="translate(7e-6 1e-6)">
<path d="M10.8098,336.242c-.87689,2.685,1.0323,4.548,2.8304,6.057c1.3875,1.154,2.8971,2.419,3.2856,4.183.7659,3.572-3.4632,6.867-2.553,10.406.3885,1.487,1.6317,2.607,2.9859,3.328c1.1211.61,2.3309.999,3.5075,1.498.5883.177,1.1766.322,1.7649.422c1.7315.299,3.563.809,4.8062,2.141c1.9535,2.119,1.7094,5.38,2.4086,8.176.0111.033.0222.067.0222.1c1.4985,5.813,8.4914,8.199,13.3864,4.715l9.8788-7.034c3.9071-5.258,2.9193-14.688-3.3521-17.773-1.332-.654-2.8527-1.031-3.9183-2.074-1.2875-1.254-1.5983-3.195-1.676-4.993-.0777-1.797,0-3.661-.7326-5.314-1.0212-2.285-3.4632-3.683-5.9273-4.105-2.8859-.499-5.783.677-8.5135-.321-2.7417-.999-5.2835-2.607-8.114-3.317-3.1079-.777-6.6932-.355-9.013,2.152-.4773.51-.8547,1.098-1.0767,1.753Z" fill="#95de64"/>
<path d="M39.7466,352.029c-.0222-.067-.0666-.122-.1221-.167-7.348-10.395-18.8585-15.332-22.7546-16.197-.222-.045-.4328.089-.4883.31-.0444.222.0888.433.3107.489c3.6852.821,14.3743,5.38,21.578,14.921-10.2118-3.273-17.8373,1.509-17.915,1.564-.1887.123-.2442.378-.1221.566.0777.111.1998.178.333.189.0777,0,.1665-.022.2331-.067.0777-.055,7.9252-4.981,18.3035-1.109c3.774,5.425,6.3824,12.359,6.116,20.99-.0111.211.1554.399.3663.422.0111,0,.0222,0,.0333,0c.222.011.4107-.167.4218-.4.2664-8.864-2.4198-15.953-6.2936-21.511Z" fill="#fff"/>
</g>
<path d="m321.48 20.847-5.694-3.306-266.63 153.84v200.76c0 1.931 0.677 3.284 1.7648 3.917h0.0111l5.6831 3.306c1.0989 0.632 2.6196 0.543 4.2956-0.422l254.5-146.85c3.352-1.931 6.071-6.635 6.071-10.506v-200.74z" fill="#d9d9d9"/>
<path d="m54.854 375.45c0 3.872 2.7195 5.437 6.0716 3.506l254.5-146.85c3.352-1.931 6.072-6.635 6.072-10.506v-200.76l-266.64 153.85v200.76z" fill="#096dd9"/>
<path d="m54.854 375.45c0 3.872 2.7195 5.437 6.0716 3.506l254.5-146.85c3.352-1.931 6.072-6.635 6.072-10.506v-200.76l-266.64 153.85v200.76z" fill="#fff" stroke="#f5f5f5" stroke-miterlimit="10"/>
<path d="m54.854 174.69v23.753l266.63-153.84v-23.753l-266.63 153.84z" fill="#1890ff"/>
<path d="m54.854 198.44-5.6942-3.306v-23.753l5.6942 3.306v23.753z" fill="#096dd9"/>
<path d="m54.854 198.44-5.6942-3.306v-23.753l5.6942 3.306v23.753z" opacity=".2"/>
<path d="m265.48 62.55v5.658c0 1.1538 0.822 1.6308 1.821 1.0539l37.106-21.467v-9.8516l-37.106 21.467c-0.999 0.5658-1.821 1.9859-1.821 3.1397z" fill="#fafafa"/>
<path d="M313.968,32.4066c1.01-.5769,1.821-.1109,1.821,1.0539v5.6581c0,1.1649-.811,2.5738-1.821,3.1507L304.4,47.7942v-9.8516l9.568-5.536Z" fill="#455a64"/>
<path d="M313.268,40.1614l-1.066-.3328c.111-.4549.167-.9208.167-1.3757c0-1.0872-.355-1.9193-.977-2.2743-.455-.2662-1.01-.2441-1.554.0777-1.199.6878-2.131,2.6404-2.131,4.4376c0,1.0873.355,1.9193.977,2.2743.211.1221.433.1776.677.1776.288,0,.588-.0888.877-.2552.721-.4105,1.343-1.2869,1.72-2.3076l1.066.3328c.044.0111.078.0222.122.0222.166,0,.322-.1109.377-.2773.067-.2108-.044-.4327-.255-.4993Zm-3.43,2.0413c-.3.1665-.555.1997-.766.0777-.355-.2108-.577-.8099-.577-1.5975c0-1.4867.799-3.2063,1.732-3.7499.177-.0998.333-.1553.477-.1553.1,0,.2.0222.289.0777.355.2108.577.8098.577,1.5975.011,1.4977-.788,3.2062-1.732,3.7498Z" fill="#fafafa"/>
<path d="m314.08 0.94365c-1.099-0.67674-2.642-0.59908-4.351 0.38829l-254.5 146.84c-3.3521 1.931-6.0715 6.635-6.0715 10.506v12.703l5.6942 3.306 266.63-153.84v-12.714c0-1.9193-0.666-3.2728-1.753-3.9051-1.021-0.59908-4.673-2.6848-5.65-3.2839z" fill="#d9d9d9"/>
<path d="m54.854 161.99c0-3.871 2.7195-8.575 6.0716-10.506l254.48-146.84c3.352-1.9304 6.072-0.3661 6.072 3.5058v12.703l-266.63 153.84v-12.703z" fill="#f0f0f0"/>
<path d="M319.784,4.26071c-1.11-.68784-2.653-.62127-4.374.3772L60.9254,151.48c-1.6761.965-3.1968,2.63-4.2846,4.527l-5.7053-3.295c1.11-1.909,2.6196-3.573,4.3068-4.538L309.727,1.32076c1.71-.97629,3.252-1.053949,4.34-.377205.999.599085,4.64,2.684785,5.661,3.272775.011.02219.045.03329.056.04438Z" fill="#d9d9d9"/>
<path d="M292.342,25.3504c-1.577.9097-2.842,3.1175-2.842,4.9258c0,1.8195,1.276,2.5517,2.842,1.642c1.576-.9097,2.841-3.1175,2.841-4.9258.011-1.8084-1.265-2.5406-2.841-1.642Z" fill="#52c41a"/>
<path d="M302.308,19.6151c-1.576.9097-2.841,3.1174-2.841,4.9258s1.276,2.5516,2.841,1.6419c1.577-.9097,2.842-3.1174,2.842-4.9258s-1.277-2.5406-2.842-1.6419Z" fill="#fa8c16"/>
<path d="m312.26 13.89c-1.576 0.9097-2.841 3.1174-2.841 4.9258 0 1.8194 1.276 2.5517 2.841 1.6419 1.577-0.9097 2.842-3.1174 2.842-4.9258 0-1.8194-1.265-2.5516-2.842-1.6419z" fill="#f5222d"/>
<path d="M74.1789,298.001c-.1887,0-.3663-.044-.5328-.144-.333-.189-.5328-.544-.5328-.921v-65.089c0-.378.1998-.733.5328-.921l78.0869-45.054c.333-.188.733-.188,1.066,0c.333.189.532.544.532.921v65.079c0,.377-.199.732-.532.92L74.7116,297.846c-.1553.111-.344.155-.5327.155Zm1.0766-65.533v62.615L151.2,251.261v-62.615L75.2555,232.468Z" fill="#40a9ff"/>
<path d="M75.101,283.024l-1.8315-1.087L96.901,242.098c.2997-.499.9324-.666,1.443-.389l15.24,8.632l16.072-32.162c.145-.3.422-.511.755-.566.322-.067.666.022.91.244l21.656,19.115-1.41,1.598-20.623-18.195-15.961,31.929c-.134.267-.367.455-.644.544-.278.089-.577.055-.833-.089l-15.3063-8.675-23.0987,38.94Z" fill="#40a9ff"/>
<path d="M109.455,234.343c-.589,0-1.144-.144-1.654-.433-1.155-.665-1.787-1.964-1.787-3.672c0-3.062,2.075-6.657,4.728-8.188c1.476-.843,2.919-.954,4.074-.288c1.154.666,1.787,1.964,1.787,3.672c0,3.062-2.076,6.657-4.729,8.188-.821.477-1.654.721-2.419.721Zm3.718-10.75c-.366,0-.81.144-1.288.421-1.953,1.121-3.596,3.972-3.596,6.224c0,.854.233,1.476.655,1.72.422.233,1.077.133,1.809-.289c1.954-1.12,3.597-3.972,3.597-6.224c0-.854-.233-1.475-.655-1.719-.145-.089-.322-.133-.522-.133Z" fill="#40a9ff"/>
<path d="M161.71,183.942L227.454,146c1.499-.865,2.709-.166,2.709,1.564v5.481c0,1.731-1.21,3.827-2.709,4.693L161.71,195.68c-1.498.865-2.708.166-2.708-1.565v-5.48c0-1.731,1.21-3.828,2.708-4.693Z" fill="#096dd9"/>
<path d="M160.09,203.357l48.661-28.08c.6-.343,1.088-.066,1.088.622v3.361c0,.688-.488,1.531-1.088,1.875l-48.661,28.08c-.6.343-1.088.066-1.088-.622v-3.361c0-.688.488-1.52,1.088-1.875Z" fill="#f5f5f5"/>
<path d="M159.002,218.19c-.222,0-.422-.111-.544-.311-.177-.299-.066-.688.233-.854l96.435-55.648c.3-.167.688-.067.855.233.178.299.067.687-.233.854l-96.435,55.648c-.1.056-.2.078-.311.078Z" fill="#f5f5f5"/>
<path d="M159.002,224.058c-.222,0-.422-.111-.544-.31-.177-.3-.066-.688.233-.854l96.435-55.649c.3-.166.688-.066.855.233.178.3.067.688-.233.854l-96.435,55.649c-.1.055-.2.077-.311.077Z" fill="#f5f5f5"/>
<path d="M159.002,229.928c-.222,0-.422-.111-.544-.311-.177-.3-.066-.688.233-.854l96.435-55.649c.3-.166.688-.066.855.233.178.3.067.688-.233.855L159.313,229.85c-.1.055-.2.078-.311.078Z" fill="#f5f5f5"/>
<path d="M159.002,235.796c-.222,0-.422-.111-.544-.31-.177-.3-.066-.688.233-.855l96.435-55.648c.3-.166.688-.067.855.233.178.3.067.688-.233.854l-96.435,55.649c-.1.044-.2.077-.311.077Z" fill="#f5f5f5"/>
<path d="M159.002,241.665c-.222,0-.422-.111-.544-.311-.177-.299-.066-.688.233-.854l96.435-55.648c.3-.167.688-.067.855.233.178.299.067.687-.233.854l-96.435,55.648c-.1.045-.2.078-.311.078Z" fill="#f5f5f5"/>
<path d="M159.002,247.523c-.222,0-.422-.111-.544-.311-.177-.299-.066-.677.233-.854l45.754-26.404c.299-.178.688-.067.854.233.178.299.067.676-.233.854l-45.753,26.404c-.1.056-.2.078-.311.078Z" fill="#f5f5f5"/>
<path d="m81.471 345.41 55.232-31.918c1.277-0.732 2.309-2.529 2.309-4.005v-29.71c0-1.475-1.032-2.074-2.309-1.331l-55.232 31.918c-1.2765 0.732-2.3088 2.529-2.3088 4.005v29.71c0 1.464 1.0323 2.063 2.3088 1.331z" fill="#fafafa" stroke="#f5f5f5" stroke-miterlimit="10" stroke-width=".9621"/>
<path d="M84.6563,312.391l10.7002-6.18c.6438-.366,1.1544-.066,1.1544.666v12.348c0,.732-.5217,1.631-1.1544,1.997l-10.7002,6.179c-.6438.366-1.1543.067-1.1543-.665v-12.348c0-.732.5216-1.631,1.1543-1.997Z" fill="#bae7ff"/>
<path d="M101.318,302.784l32.9-19.027c.255-.144.466-.033.466.266v1.62c0,.3-.211.655-.466.799l-32.9,19.026c-.256.145-.466.034-.466-.266v-1.62c0-.299.21-.654.466-.798Z" fill="#bae7ff"/>
<path d="M101.318,308.941l32.9-19.027c.255-.144.466-.033.466.267v1.619c0,.3-.211.655-.466.799l-32.9,19.027c-.256.144-.466.033-.466-.267v-1.619c0-.289.21-.644.466-.799Z" fill="#bae7ff"/>
<path d="M101.318,315.109l32.9-19.026c.255-.144.466-.034.466.266v1.62c0,.299-.211.654-.466.799l-32.9,19.026c-.256.144-.466.033-.466-.266v-1.62c0-.288.21-.655.466-.799Z" fill="#bae7ff"/>
<path d="M83.9681,331.251l50.2489-29c.255-.144.466-.022.466.266v1.62c0,.3-.211.655-.466.799l-50.2489,29c-.2553.144-.4661.022-.4661-.266v-1.62c0-.299.2108-.654.4661-.799Z" fill="#bae7ff"/>
<path d="M83.9681,337.42l50.2489-29c.255-.145.466-.023.466.266v1.62c0,.299-.211.654-.466.798L83.9681,340.105c-.2553.144-.4661.022-.4661-.267v-1.619c0-.3.2108-.655.4661-.799Z" fill="#bae7ff"/>
<path d="M77.0523,308.23c-.1332,0-.2775-.066-.3441-.199-.111-.189-.0444-.433.1443-.544l1.1543-.666c.1887-.111.4329-.044.5439.145.111.188.0444.432-.1443.543l-1.1543.666c-.0555.044-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M80.648,306.167c-.1332,0-.2775-.067-.3441-.2-.111-.188-.0444-.432.1443-.543l2.4308-1.409c.1887-.111.4329-.045.5439.144s.0444.433-.1443.544l-2.4308,1.409c-.0666.033-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M85.5112,303.36c-.1332,0-.2775-.066-.3441-.199-.111-.189-.0444-.433.1443-.544l2.4309-1.409c.1887-.111.4329-.044.5439.144.111.189.0444.433-.1443.544l-2.4309,1.409c-.0666.033-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M90.3726,300.543c-.1332,0-.2775-.067-.3441-.2-.111-.189-.0444-.433.1443-.544l2.4308-1.409c.1887-.111.4329-.044.5439.144.111.189.0444.433-.1443.544l-2.4308,1.409c-.0555.044-.1221.056-.1998.056Z" fill="#bae7ff"/>
<path d="M95.2456,297.735c-.1332,0-.2775-.066-.3441-.199-.111-.189-.0444-.433.1443-.544l2.4309-1.409c.1887-.111.4329-.044.5439.144.111.189.0444.433-.1443.544l-2.4309,1.409c-.0666.044-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M100.107,294.929c-.1333,0-.2776-.067-.3442-.2-.111-.189-.0444-.433.1443-.544l2.4309-1.409c.189-.11.433-.044.544.145.111.188.044.432-.144.543l-2.431,1.409c-.056.045-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M105.158,292.022c-.133,0-.278-.067-.344-.2-.111-.188-.045-.432.144-.543l2.431-1.409c.188-.111.433-.045.544.144s.044.433-.145.544l-2.43,1.409c-.067.033-.134.055-.2.055Z" fill="#bae7ff"/>
<path d="M110.197,289.105c-.133,0-.278-.067-.344-.2-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.145.544l-2.43,1.409c-.056.044-.134.056-.2.056Z" fill="#bae7ff"/>
<path d="M115.248,286.187c-.134,0-.278-.067-.345-.2-.111-.189-.044-.433.145-.544l2.431-1.409c.188-.111.433-.044.544.145.111.188.044.432-.145.543l-2.431,1.409c-.066.044-.133.056-.199.056Z" fill="#bae7ff"/>
<path d="M120.287,283.28c-.134,0-.278-.067-.344-.2-.111-.188-.045-.433.144-.543l2.431-1.409c.188-.111.433-.045.544.144.111.188.044.432-.145.543l-2.431,1.409c-.055.034-.122.056-.199.056Z" fill="#bae7ff"/>
<path d="M125.337,280.362c-.133,0-.277-.066-.344-.199-.111-.189-.044-.433.145-.544l2.43-1.409c.189-.111.433-.044.544.144.111.189.045.433-.144.544l-2.431,1.409c-.066.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M130.386,277.456c-.133,0-.277-.067-.344-.2-.111-.189-.044-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.045.432-.144.543l-2.431,1.409c-.067.033-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M135.427,274.538c-.133,0-.277-.067-.344-.2-.111-.189-.044-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.045.432-.144.543l-2.431,1.409c-.067.045-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M140.476,271.62c-.133,0-.277-.066-.344-.2-.111-.188-.044-.432.144-.543l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.067.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M145.517,268.713c-.133,0-.277-.066-.344-.199-.111-.189-.044-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.056.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M150.566,265.795c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.067.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M155.605,262.889c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.056.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M160.656,259.971c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.145.544l-2.43,1.409c-.067.044-.134.055-.2.055Z" fill="#bae7ff"/>
<path d="M165.695,257.053c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.145.544l-2.43,1.409c-.056.044-.122.055-.2.055Z" fill="#bae7ff"/>
<path d="M170.746,254.147c-.134,0-.278-.067-.344-.2-.111-.189-.045-.433.144-.544l2.431-1.409c.188-.111.433-.044.544.144.111.189.044.433-.145.544l-2.431,1.409c-.066.033-.133.056-.199.056Z" fill="#bae7ff"/>
<path d="M175.796,251.229c-.133,0-.277-.067-.344-.2-.111-.188-.044-.432.145-.543l2.43-1.409c.189-.111.433-.045.544.144s.045.433-.144.544l-2.431,1.409c-.066.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M180.835,248.322c-.133,0-.277-.066-.344-.199-.111-.189-.044-.433.145-.544l2.431-1.409c.188-.111.432-.044.543.144.111.189.045.433-.144.544l-2.431,1.409c-.055.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M185.708,245.515c-.133,0-.277-.066-.344-.2-.111-.188-.044-.432.145-.543l2.431-1.409c.188-.111.432-.044.543.144.111.189.045.433-.144.544l-2.431,1.409c-.066.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M190.57,242.697c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.145.544l-2.43,1.409c-.067.044-.134.055-.2.055Z" fill="#bae7ff"/>
<path d="M195.431,239.891c-.133,0-.277-.067-.344-.2-.111-.189-.044-.433.144-.544l1.155-.665c.188-.111.433-.045.544.144.111.188.044.432-.145.543l-1.154.666c-.056.044-.122.056-.2.056Z" fill="#bae7ff"/>
<path d="M75.2765,352.364c-.222,0-.3995-.178-.3995-.4v-1.331c0-.222.1775-.4.3995-.4s.3996.178.3996.4v1.331c0,.211-.1776.4-.3996.4Z" fill="#bae7ff"/>
<path d="M75.2765,348.469c-.222,0-.3995-.177-.3995-.399v-2.563c0-.222.1775-.399.3995-.399s.3996.177.3996.399v2.563c0,.222-.1776.399-.3996.399Zm0-5.125c-.222,0-.3995-.178-.3995-.4v-2.563c0-.221.1775-.399.3995-.399s.3996.178.3996.399v2.563c0,.222-.1776.4-.3996.4Zm0-5.115c-.222,0-.3995-.177-.3995-.399v-2.563c0-.222.1775-.399.3995-.399s.3996.177.3996.399v2.563c0,.222-.1776.399-.3996.399Zm0-5.114c-.222,0-.3995-.178-.3995-.4v-2.562c0-.222.1775-.4.3995-.4s.3996.178.3996.4v2.562c0,.222-.1776.4-.3996.4Zm0-5.115c-.222,0-.3995-.177-.3995-.399v-2.563c0-.222.1775-.399.3995-.399s.3996.177.3996.399v2.563c0,.222-.1776.399-.3996.399Zm0-5.114c-.222,0-.3995-.178-.3995-.399v-2.563c0-.222.1775-.4.3995-.4s.3996.178.3996.4v2.563c0,.221-.1776.399-.3996.399Zm0-5.126c-.222,0-.3995-.177-.3995-.399v-2.563c0-.222.1775-.399.3995-.399s.3996.177.3996.399v2.563c0,.222-.1776.399-.3996.399Z" fill="#bae7ff"/>
<path d="M75.2765,312.646c-.222,0-.3995-.177-.3995-.399v-1.332c0-.221.1775-.399.3995-.399s.3996.178.3996.399v1.332c0,.222-.1776.399-.3996.399Z" fill="#bae7ff"/>
<path d="M195.464,285.022c-.133,0-.277-.067-.344-.2-.111-.188-.044-.432.145-.543l1.154-.666c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-1.155.665c-.055.045-.122.056-.2.056Z" fill="#bae7ff"/>
<path d="M80.648,351.31c-.1332,0-.2775-.067-.3441-.2-.111-.189-.0444-.433.1443-.544l2.4308-1.409c.1887-.111.4329-.044.5439.145.111.188.0444.432-.1443.543l-2.4308,1.409c-.0666.045-.1332.056-.1998.056Z" fill="#bae7ff"/>
<path d="M85.521,348.502c-.1332,0-.2775-.066-.3441-.199-.111-.189-.0444-.433.1443-.544l2.4309-1.409c.1887-.111.4328-.044.5438.144.111.189.0444.433-.1442.544l-2.4309,1.409c-.0666.033-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M90.3823,345.685c-.1332,0-.2775-.067-.3441-.2-.111-.189-.0444-.433.1443-.544l2.4309-1.409c.1887-.111.4329-.044.5439.145.111.188.0444.432-.1443.543l-2.4309,1.409c-.0555.045-.1221.056-.1998.056Z" fill="#bae7ff"/>
<path d="M95.2554,342.878c-.1332,0-.2775-.067-.3441-.2-.111-.188-.0444-.432.1443-.543l2.4308-1.409c.1887-.111.4329-.045.5439.144s.0444.433-.1443.544l-2.4308,1.408c-.0555.045-.1332.056-.1998.056Z" fill="#bae7ff"/>
<path d="M100.128,340.071c-.1328,0-.2771-.066-.3437-.199-.111-.189-.0444-.433.1443-.544l2.4304-1.409c.189-.111.433-.044.544.144.111.189.045.433-.144.544l-2.431,1.409c-.066.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M105.167,337.153c-.133,0-.277-.066-.344-.199-.111-.189-.044-.433.145-.544l2.431-1.409c.188-.111.432-.044.543.144.111.189.045.433-.144.544l-2.431,1.409c-.055.044-.122.055-.2.055Z" fill="#bae7ff"/>
<path d="M110.218,334.247c-.133,0-.277-.066-.344-.2-.111-.188-.044-.432.144-.543l2.431-1.409c.189-.111.433-.045.544.144s.045.433-.144.544l-2.431,1.409c-.067.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M115.269,331.329c-.133,0-.277-.066-.344-.2-.111-.188-.044-.432.144-.543l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.067.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M120.308,328.411c-.133,0-.277-.066-.344-.2-.111-.188-.044-.432.144-.543l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.056.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M125.359,325.504c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.067.033-.134.055-.2.055Z" fill="#bae7ff"/>
<path d="M130.398,322.587c-.133,0-.278-.067-.344-.2-.111-.188-.045-.432.144-.543l2.431-1.409c.189-.111.433-.045.544.144s.044.433-.144.544l-2.431,1.408c-.056.045-.122.056-.2.056Z" fill="#bae7ff"/>
<path d="M135.449,319.68c-.133,0-.278-.066-.344-.199-.111-.189-.045-.433.144-.544l2.431-1.409c.188-.111.433-.044.544.144.111.189.044.433-.145.544l-2.43,1.409c-.067.033-.134.055-.2.055Z" fill="#bae7ff"/>
<path d="M140.498,316.762c-.134,0-.278-.066-.345-.199-.111-.189-.044-.433.145-.544l2.431-1.409c.188-.111.433-.044.544.144.111.189.044.433-.145.544l-2.431,1.409c-.066.033-.133.055-.199.055Z" fill="#bae7ff"/>
<path d="M145.539,313.845c-.134,0-.278-.067-.345-.2-.111-.189-.044-.433.145-.544l2.431-1.408c.188-.111.433-.045.544.144.111.188.044.432-.145.543l-2.431,1.409c-.055.045-.133.056-.199.056Z" fill="#bae7ff"/>
<path d="M150.587,310.938c-.133,0-.277-.067-.344-.2-.111-.188-.044-.432.145-.543l2.43-1.409c.189-.111.433-.045.544.144s.045.433-.144.544l-2.431,1.409c-.066.033-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M155.626,308.02c-.133,0-.277-.067-.344-.2-.111-.188-.044-.432.145-.543l2.431-1.409c.188-.111.432-.045.543.144s.045.433-.144.544l-2.431,1.409c-.055.044-.133.055-.2.055Z" fill="#bae7ff"/>
<path d="M160.677,305.103c-.133,0-.277-.067-.344-.2-.111-.189-.044-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.045.432-.144.543l-2.431,1.409c-.067.044-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M165.716,302.196c-.133,0-.277-.067-.344-.2-.111-.188-.044-.433.145-.543l2.43-1.409c.189-.111.433-.045.544.144.111.188.045.432-.144.543l-2.431,1.409c-.055.034-.122.056-.2.056Z" fill="#bae7ff"/>
<path d="M170.767,299.278c-.133,0-.277-.067-.344-.2-.111-.188-.044-.432.144-.543l2.431-1.409c.189-.111.433-.045.544.144.111.188.044.432-.144.543l-2.431,1.409c-.067.045-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M175.818,296.372c-.133,0-.278-.067-.344-.2-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.044.432-.144.543l-2.431,1.409c-.067.033-.134.056-.2.056Z" fill="#bae7ff"/>
<path d="M180.857,293.454c-.133,0-.278-.067-.344-.2-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.044.432-.144.543l-2.431,1.409c-.056.033-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M185.73,290.647c-.133,0-.278-.067-.344-.2-.111-.189-.045-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.144.111.189.044.433-.144.544l-2.431,1.409c-.067.033-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M190.603,287.829c-.133,0-.277-.067-.344-.2-.111-.189-.044-.433.144-.544l2.431-1.409c.189-.111.433-.044.544.145.111.188.044.432-.144.543l-2.431,1.409c-.067.045-.133.056-.2.056Z" fill="#bae7ff"/>
<path d="M77.0523,353.384c-.1332,0-.2775-.066-.3441-.2-.111-.188-.0444-.432.1443-.543l1.1543-.666c.1887-.111.4329-.044.5439.144.111.189.0444.433-.1443.544l-1.1543.666c-.0555.033-.1332.055-.1998.055Z" fill="#bae7ff"/>
<path d="M198.407,241.566c-.222,0-.399-.178-.399-.4v-1.331c0-.222.177-.399.399-.399s.4.177.4.399v1.331c0,.222-.178.4-.4.4Z" fill="#bae7ff"/>
<path d="M198.407,277.377c-.222,0-.399-.177-.399-.399v-2.563c0-.222.177-.399.399-.399s.4.177.4.399v2.563c0,.222-.178.399-.4.399Zm0-5.114c-.222,0-.399-.177-.399-.399v-2.563c0-.222.177-.399.399-.399s.4.177.4.399v2.563c0,.222-.178.399-.4.399Zm0-5.114c-.222,0-.399-.178-.399-.4v-2.563c0-.221.177-.399.399-.399s.4.178.4.399v2.563c0,.222-.178.4-.4.4Zm0-5.115c-.222,0-.399-.177-.399-.399v-2.563c0-.222.177-.399.399-.399s.4.177.4.399v2.563c0,.222-.178.399-.4.399Zm0-5.114c-.222,0-.399-.178-.399-.4v-2.562c0-.222.177-.4.399-.4s.4.178.4.4v2.562c0,.222-.178.4-.4.4Zm0-5.115c-.222,0-.399-.177-.399-.399v-2.563c0-.222.177-.399.399-.399s.4.177.4.399v2.563c0,.211-.178.399-.4.399Zm0-5.125c-.222,0-.399-.178-.399-.399v-2.563c0-.222.177-.4.399-.4s.4.178.4.4v2.563c0,.221-.178.399-.4.399Z" fill="#bae7ff"/>
<path d="M198.407,281.272c-.222,0-.399-.178-.399-.4v-1.331c0-.222.177-.399.399-.399s.4.177.4.399v1.331c0,.222-.178.4-.4.4Z" fill="#bae7ff"/>
<path d="M196.632,241.255c-.067,0-.133-.022-.2-.055-.122-.067-.2-.2-.2-.344v-4.105c0-.144.078-.277.2-.344l3.552-2.052c.122-.067.278-.067.4,0c.122.066.2.199.2.344v4.104c0,.145-.078.278-.2.344l-3.552,2.053c-.067.044-.133.055-.2.055Zm.4-4.271v3.184l2.752-1.587v-3.184l-2.752,1.587Z" fill="#bae7ff"/>
<path d="M196.632,286.408c-.067,0-.133-.022-.2-.055-.122-.067-.2-.2-.2-.344v-4.105c0-.144.078-.277.2-.344l3.552-2.052c.122-.067.278-.067.4,0c.122.066.2.2.2.344v4.105c0,.144-.078.277-.2.344l-3.552,2.052c-.067.033-.133.055-.2.055Zm.4-4.271v3.184l2.752-1.586v-3.184l-2.752,1.586Z" fill="#bae7ff"/>
<path d="M73.5012,312.335c-.0666,0-.1332-.022-.1998-.055-.1221-.067-.1998-.2-.1998-.344v-4.105c0-.144.0777-.277.1998-.344l3.5519-2.052c.1221-.067.2775-.067.3996,0c.1221.066.1998.199.1998.344v4.104c0,.145-.0777.278-.1998.344L73.701,312.28c-.0666.044-.1332.055-.1998.055Zm.3995-4.271v3.184l2.7528-1.586v-3.184l-2.7528,1.586Z" fill="#bae7ff"/>
<path d="M73.5012,357.489c-.0666,0-.1332-.023-.1998-.056-.1221-.066-.1998-.2-.1998-.344v-4.105c0-.144.0777-.277.1998-.344l3.5519-2.052c.1221-.067.2775-.067.3996,0s.1998.2.1998.344v4.105c0,.144-.0777.277-.1998.344l-3.5519,2.052c-.0666.033-.1332.056-.1998.056Zm.3995-4.272v3.184l2.7528-1.586v-3.184l-2.7528,1.586Z" fill="#bae7ff"/>
<path d="M200.193,261.846c0,1.32-.799,2.851-1.787,3.417s-1.787-.034-1.787-1.354.799-2.851,1.787-3.417s1.787.034,1.787,1.354Z" fill="#bae7ff"/>
<path d="M141.043,270.732c0,1.32-.799,2.851-1.787,3.417s-1.787-.033-1.787-1.353.799-2.851,1.787-3.417c.988-.577,1.787.033,1.787,1.353Z" fill="#bae7ff"/>
<path d="M141.043,315.874c0,1.321-.799,2.852-1.787,3.417-.988.566-1.787-.033-1.787-1.353s.799-2.851,1.787-3.417s1.787.044,1.787,1.353Z" fill="#bae7ff"/>
<path d="M77.0644,332.993c0,1.32-.7992,2.851-1.7871,3.417s-1.7871-.033-1.7871-1.353c0-1.321.7992-2.852,1.7871-3.417.9879-.577,1.7871.033,1.7871,1.353Z" fill="#bae7ff"/>
<path d="M143.675,277.522c-.111,0-.222-.055-.289-.166-.089-.156-.033-.355.122-.444l50.637-29.222c.156-.089.356-.033.456.122.088.155.033.355-.123.444l-50.637,29.222c-.055.033-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,280.606c-.111,0-.222-.055-.289-.166-.089-.156-.033-.355.122-.444l50.637-29.222c.156-.089.356-.045.456.122.088.155.033.355-.123.444l-50.637,29.222c-.055.022-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,283.679c-.111,0-.222-.055-.289-.166-.089-.155-.033-.355.122-.444l50.637-29.222c.156-.089.356-.044.456.122.088.155.033.355-.123.444l-50.637,29.222c-.055.033-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,286.764c-.111,0-.222-.056-.289-.167-.089-.155-.033-.355.122-.443l50.637-29.222c.156-.089.356-.034.456.122.088.155.033.355-.123.443l-50.637,29.222c-.055.034-.111.045-.166.045Z" fill="#8c8c8c"/>
<path d="M143.675,289.848c-.111,0-.222-.056-.289-.167-.089-.155-.033-.355.122-.443l50.637-29.223c.156-.088.356-.044.456.123.088.155.033.355-.123.443l-50.637,29.222c-.055.022-.111.045-.166.045Z" fill="#8c8c8c"/>
<path d="M143.675,292.921c-.111,0-.222-.056-.289-.167-.089-.155-.033-.355.122-.444l24.02-13.867c.156-.089.355-.045.444.122.089.155.033.355-.122.444l-24.02,13.867c-.044.034-.1.045-.155.045Z" fill="#8c8c8c"/>
<path d="M143.675,300.021c-.111,0-.222-.055-.289-.166-.089-.156-.033-.355.122-.444l50.637-29.222c.156-.089.356-.045.456.122.088.155.033.355-.123.444l-50.637,29.222c-.055.033-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,303.094c-.111,0-.222-.055-.289-.166-.089-.155-.033-.355.122-.444l50.637-29.222c.156-.089.356-.044.456.122.088.155.033.355-.123.444L143.841,303.05c-.055.033-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,306.178c-.111,0-.222-.055-.289-.166-.089-.155-.033-.355.122-.444l50.637-29.222c.156-.089.356-.033.456.122.088.155.033.355-.123.444l-50.637,29.222c-.055.033-.111.044-.166.044Z" fill="#8c8c8c"/>
<path d="M143.675,309.262c-.111,0-.222-.055-.289-.166-.089-.155-.033-.355.122-.444l24.02-13.867c.156-.089.355-.034.455.122.089.155.034.355-.122.454l-24.02,13.868c-.055.022-.111.033-.166.033Z" fill="#8c8c8c"/>
<path d="M220.151,272.684c-.044,0-.089-.011-.133-.033-.078-.044-.133-.133-.133-.233v-46.607c0-.099.055-.177.133-.232l37.495-21.634c.078-.044.189-.044.266,0c.078.044.134.133.134.233v46.606c0,.1-.056.189-.134.233l-37.495,21.634c-.044.022-.088.033-.133.033Zm.267-46.717v45.996l36.962-21.334v-45.996l-36.962,21.334Z" fill="#f5f5f5"/>
<path d="M223.038,251.039c-.045,0-.089-.011-.133-.033-.078-.044-.134-.133-.134-.233v-23.298c0-.099.056-.177.134-.233l31.723-18.305c.078-.044.189-.044.266,0c.078.044.133.133.133.233v23.298c0,.1-.055.177-.133.233l-31.723,18.305c-.044.022-.089.033-.133.033Zm.266-23.408v22.687l31.191-18.006v-22.687l-31.191,18.006Z" fill="#bae7ff"/>
<path d="M223.336,253.924l8.036-4.637c.167-.1.311-.023.311.177v.954c0,.2-.133.433-.311.533l-8.036,4.637c-.166.1-.311.022-.311-.177v-.955c.011-.188.145-.432.311-.532Z" fill="#bae7ff"/>
<path d="M223.336,257.252l30.147-17.351c.167-.1.311-.022.311.177v.954c0,.2-.133.433-.311.533l-30.147,17.351c-.166.1-.311.022-.311-.177v-.954c.011-.189.145-.433.311-.533Z" fill="#f5f5f5"/>
<path d="M223.336,263.92l30.147-17.351c.167-.1.311-.022.311.177v.954c0,.2-.133.433-.311.533l-30.147,17.351c-.166.1-.311.022-.311-.177v-.954c.011-.2.145-.444.311-.533Z" fill="#f5f5f5"/>
<path d="M223.336,260.581l30.147-17.351c.167-.1.311-.022.311.177v.955c0,.199-.133.432-.311.532l-30.147,17.351c-.166.1-.311.023-.311-.177v-.954c.011-.189.145-.433.311-.533Z" fill="#f5f5f5"/>
<path d="M254.76,232.734c-.011,0-.023,0-.045,0l-11.033-1.742c-.144-.022-.244-.155-.222-.299s.144-.244.3-.222l11.033,1.742c.144.022.244.155.222.299-.011.122-.122.222-.255.222Z" fill="#bae7ff"/>
<path d="M234.081,229.484c-.011,0-.022,0-.044,0l-11.044-1.742c-.145-.022-.245-.155-.222-.299.022-.144.155-.244.299-.222l11.045,1.742c.144.022.244.155.222.299-.011.122-.134.222-.256.222Z" fill="#bae7ff"/>
<path d="M223.037,251.04c-.055,0-.111-.022-.166-.056-.122-.088-.144-.255-.044-.377l11.033-14.478c.089-.111.255-.144.377-.044.122.089.144.255.045.377L223.248,250.94c-.055.067-.133.1-.211.1Z" fill="#bae7ff"/>
<path d="M243.705,223.925c-.055,0-.111-.022-.166-.055-.122-.089-.144-.255-.044-.377l11.055-14.5c.089-.111.255-.145.377-.045.122.089.145.255.045.377l-11.056,14.5c-.055.067-.133.1-.211.1Z" fill="#bae7ff"/>
<path d="M238.898,228.307c-.644.366-1.166,1.476-1.166,2.452s.522,1.487,1.166,1.121s1.165-1.476,1.165-2.452-.532-1.498-1.165-1.121Z" fill="#bae7ff"/>
<path d="M241.385,225.29l-.888.51v-.466c0-.3-.155-.444-.344-.333l-2.52,1.453c-.188.111-.344.444-.344.733v.466l-.888.51c-.399.233-.721.91-.721,1.52v4.515c0,.61.322.921.721.688l4.984-2.873c.4-.233.721-.91.721-1.52v-4.516c0-.61-.321-.92-.721-.687Zm-2.486,7.477c-.955.555-1.743-.2-1.743-1.675c0-1.476.777-3.129,1.743-3.683.954-.555,1.742.199,1.742,1.675-.011,1.475-.788,3.128-1.742,3.683Z" fill="#bae7ff"/>
<path d="M266.294,246.047c-.045,0-.089-.011-.133-.033-.078-.045-.134-.133-.134-.233v-46.607c0-.1.056-.177.134-.233l37.495-21.633c.077-.045.188-.045.266,0c.078.044.133.133.133.233v46.606c0,.1-.055.189-.133.233l-37.495,21.634c-.033.022-.078.033-.133.033Zm.266-46.717v45.996l36.962-21.334v-45.996L266.56,199.33Z" fill="#f5f5f5"/>
<path d="M269.18,224.413c-.044,0-.088-.011-.133-.033-.077-.044-.133-.133-.133-.233v-23.298c0-.099.056-.177.133-.233l31.723-18.305c.078-.044.189-.044.267,0s.133.133.133.233v23.298c0,.1-.055.188-.133.233L269.314,224.38c-.034.022-.089.033-.134.033Zm.267-23.419v22.687l31.19-18.006v-22.687l-31.19,18.006Z" fill="#bae7ff"/>
<path d="M269.49,227.298l8.037-4.637c.166-.1.311-.023.311.177v.954c0,.2-.134.433-.311.533l-8.037,4.637c-.166.1-.31.022-.31-.177v-.954c0-.2.144-.433.31-.533Z" fill="#bae7ff"/>
<path d="M269.49,230.626l30.148-17.351c.166-.1.31-.022.31.177v.955c0,.199-.133.432-.31.532L269.49,232.29c-.166.1-.31.022-.31-.177v-.954c0-.2.144-.433.31-.533Z" fill="#f5f5f5"/>
<path d="M269.49,237.283l30.148-17.351c.166-.1.31-.023.31.177v.954c0,.2-.133.433-.31.533L269.49,238.947c-.166.1-.31.022-.31-.177v-.955c0-.199.144-.432.31-.532Z" fill="#f5f5f5"/>
<path d="M269.49,233.954l30.148-17.351c.166-.1.31-.022.31.178v.954c0,.199-.133.432-.31.532L269.49,235.618c-.166.1-.31.023-.31-.177v-.954c0-.2.144-.433.31-.533Z" fill="#f5f5f5"/>
<path d="M285.042,198.342c-2.387,1.376-4.329,4.737-4.329,7.489c0,2.762,1.942,3.871,4.329,2.496c2.386-1.376,4.329-4.737,4.329-7.489c0-2.751-1.932-3.872-4.329-2.496Zm2.264,4.049l-3.23,3.861c-.311.377-.566.289-.566-.211v-3.616c0-.488.4-.877.888-.855l2.597.1c.489.023.633.344.311.721Z" fill="#bae7ff"/>
<path d="M255.649,100.803c-.422-.189-.988-.134-1.599.221L80.0167,201.271c-1.3208.766-2.3975,2.63-2.3975,4.161v10.062c0,.732.2442,1.265.6549,1.531-.4107-.266-4.0404-2.319-4.4844-2.585-.4217-.255-.6881-.788-.6881-1.542v-10.062c0-1.531,1.0766-3.395,2.4086-4.161L249.533,98.4173c.677-.3883,1.287-.4105,1.72-.1553.389.233,3.541,2.019,4.396,2.541Z" fill="#096dd9"/>
<path d="M80.0161,201.271L254.05,101.025c1.332-.766,2.397-.145,2.397,1.386v10.063c0,1.531-1.076,3.395-2.397,4.16L80.0161,216.881c-1.332.765-2.4087.144-2.4087-1.387v-10.062c0-1.531,1.0767-3.395,2.4087-4.161Z" fill="#91d5ff"/>
<path d="M78.3185,203.069l-4.5066-2.608c-.4328.755-.7103,1.609-.7103,2.375v10.062c0,.754.2664,1.287.6881,1.542.444.266,4.0737,2.319,4.4844,2.585-.4107-.266-.6549-.799-.6549-1.531v-10.062c0-.766.2664-1.609.6993-2.363Z" opacity=".15"/>
<path d="M255.649,100.803c-.854-.522-4.007-2.308-4.395-2.541-.433-.2552-1.044-.233-1.721.1553L75.5108,198.675c-.666.377-1.2654,1.043-1.6983,1.786l4.5065,2.608c.4329-.755,1.0323-1.409,1.6983-1.798L254.051,101.024c.61-.355,1.176-.41,1.598-.221Z" fill="#fff" opacity=".4"/>
<path d="M247.434,112.651c0,1.442-1.076,3.217-2.408,3.994l-58.319,33.649c-1.331.765-2.408.221-2.408-1.221s1.077-3.217,2.397-3.994l58.319-33.648c1.343-.766,2.419-.222,2.419,1.22Z" fill="#fafafa"/>
<path d="M267.649,97.5518l93.183-53.7845c1.198-.6878,2.164-.1331,2.164,1.2537v84.094c0,1.375-.966,3.062-2.164,3.75l-93.183,53.784c-1.199.688-2.165.133-2.165-1.254v-84.093c0-1.3871.966-3.0623,2.165-3.7502Z" fill="#f0f0f0"/>
<path d="M495.62,287.328c11.977,6.745,12.099,17.884.267,24.884-11.833,7.001-31.124,7.212-43.112.466-11.977-6.745-12.099-17.883-.266-24.884c11.832-7,31.135-7.2,43.111-.466Z" fill="#fafafa"/>
<path d="m484.92 129.11v174.57l8.591-6.823 1.343-171.32-9.934 3.572z" fill="#d9d9d9"/>
<path d="m474.17 123.4c3.208-2.974 12.776 2.618 12.776 5.791l-2.03 174.45-17.816-7.357 7.07-172.88z" fill="#f0f0f0"/>
<path d="M476.207,93.1135c3.208-2.6737,6.393-3.3837,8.935-2.3742v-.0111l15.784,7.533-.366.9984c1.032,1.3094,1.798,3.0844,2.186,5.3254c1.399,8.065-2.564,18.86-8.857,24.096-1.721,1.432-3.441,2.286-5.051,2.63l-.377,1.043-15.74-7.522c-2.641-.843-4.65-3.462-5.372-7.633-1.398-8.054,2.564-18.838,8.858-24.0855Z" fill="#1890ff"/>
<path d="M476.207,93.1135c3.208-2.6737,6.393-3.3837,8.935-2.3742v-.0111l15.784,7.533-.366.9984c1.032,1.3094,1.798,3.0844,2.186,5.3254c1.399,8.065-2.564,18.86-8.857,24.096-1.721,1.432-3.441,2.286-5.051,2.63l-.377,1.043-15.74-7.522c-2.641-.843-4.65-3.462-5.372-7.633-1.398-8.054,2.564-18.838,8.858-24.0855Z" opacity=".15"/>
<g transform="translate(1e-6 1e-6)">
<path d="m417.9 109.38-20.391 38.331-6.504-4.139 20.324-37.986 6.571 3.794z" fill="#f0f0f0"/>
<path d="m378.54 158.89 18.97-11.183-6.505-4.138-19.047 11.538 6.582 3.783z" fill="#f0f0f0"/>
<path d="m421.14 111.11-3.241-1.731-20.39 38.331-18.97 11.183v3.639l22.022-13.114 20.579-38.308z" fill="#d9d9d9"/>
<path d="M425.28,104.818l51.836,11.349c2.897-.466,4.873-3.195,4.406-6.102-.466-2.895-3.196-4.87-6.104-4.404-.056.011-.134.022-.189.033l-51.381-8.8307c-2.187.4327-3.608,2.5627-3.175,4.7487.422,2.141,2.476,3.55,4.607,3.206Z" fill="#f0f0f0"/>
<path d="M425.28,104.818l51.836,11.349c2.897-.466,4.873-3.195,4.406-6.102-.466-2.895-3.196-4.87-6.104-4.404-.056.011-.134.022-.189.033l-51.381-8.8307c-2.187.4327-3.608,2.5627-3.175,4.7487.422,2.141,2.476,3.55,4.607,3.206Z" fill="#f0f0f0" opacity=".7"/>
<path d="M412.326,86.7785c2.287-1.8971,4.551-2.4074,6.36-1.6863v-.0111l11.156,5.7246-.256.71c.733.9319,1.288,2.1967,1.554,3.7942.999,5.7361-1.831,13.4131-6.304,17.1401-1.221,1.021-2.442,1.631-3.597,1.875l-.266.744-11.122-5.725v-.011c-1.876-.599-3.308-2.452-3.83-5.414-.987-5.7357,1.832-13.4128,6.305-17.1405Z" fill="#096dd9"/>
<path d="M412.326,86.7785c2.287-1.8971,4.551-2.4074,6.36-1.6863v-.0111l11.156,5.7246-.256.71c.733.9319,1.288,2.1967,1.554,3.7942.999,5.7361-1.831,13.4131-6.304,17.1401-1.221,1.021-2.442,1.631-3.597,1.875l-.266.744-11.122-5.725v-.011c-1.876-.599-3.308-2.452-3.83-5.414-.987-5.7357,1.832-13.4128,6.305-17.1405Z" opacity=".15"/>
<path d="m423.72 92.592c-4.473 3.7277-7.293 11.405-6.305 17.14 0.999 5.736 5.428 7.367 9.901 3.639 4.473-3.727 7.293-11.405 6.305-17.14-0.988-5.7468-5.428-7.3776-9.901-3.6389z" fill="#1890ff"/>
<path d="M421.639,106.205c.122.687.344,1.253.644,1.675.887,1.298,2.486,1.398,4.095.066c2.132-1.786,3.486-5.447,3.008-8.1982-.31-1.7862-1.298-2.7292-2.541-2.6848-.689,0-1.432.3107-2.187.9541-2.142,1.7862-3.497,5.4469-3.019,8.1879Z" fill="#096dd9"/>
<path d="M421.639,106.205c.122.687.344,1.253.644,1.675.887,1.298,2.486,1.398,4.095.066c2.132-1.786,3.486-5.447,3.008-8.1982-.31-1.7862-1.298-2.7292-2.541-2.6848-.689,0-1.432.3107-2.187.9541-2.142,1.7862-3.497,5.4469-3.019,8.1879Z" opacity=".3"/>
<path d="M421.639,106.205c.122.687.344,1.253.644,1.675.688,0,1.442-.322,2.197-.954c2.131-1.786,3.486-5.448,3.008-8.1878-.122-.6878-.344-1.2536-.643-1.6752-.689,0-1.432.3106-2.187.9541-2.142,1.7861-3.497,5.4469-3.019,8.1879Z" fill="#455a64"/>
<path d="m371.96 158.74 6.582 3.783v-3.627l-6.582-3.784v3.628z" fill="#f0f0f0"/>
<path d="m418.53 94.4-6.582-3.8164-21.689-27.913 5.328-1.0872 22.943 32.816z" fill="#f0f0f0"/>
<path d="M384.245,65.2119c-.378-.1664-.888-.122-1.443.1997L289.619,119.196c-1.188.688-2.165,2.374-2.165,3.75v84.094c0,.654.222,1.142.589,1.375-.367-.244-3.641-2.085-4.041-2.329-.377-.233-.621-.71-.621-1.387v-84.094c0-1.376.966-3.062,2.175-3.75l93.172-53.7843c.611-.355,1.166-.3661,1.554-.1442.355.1997,3.197,1.8083,3.963,2.2854Z" fill="#096dd9" opacity=".7"/>
<path d="M289.62,119.185l93.182-53.7846c1.199-.6878,2.165-.1331,2.165,1.2536v84.094c0,1.375-.966,3.062-2.165,3.75L289.62,208.282c-1.199.688-2.165.133-2.165-1.254v-84.093c0-1.376.966-3.062,2.165-3.75Z" fill="#1890ff"/>
<path d="M288.088,120.805l-4.063-2.341c-.399.677-.632,1.442-.632,2.141v84.094c0,.677.244,1.165.621,1.387.4.244,3.674,2.085,4.04,2.329-.366-.244-.588-.721-.588-1.375v-84.094c-.011-.699.233-1.465.622-2.141Z" opacity=".15"/>
<path d="M384.245,65.2117c-.765-.466-3.607-2.0746-3.962-2.2854-.389-.233-.944-.2108-1.554.1442L285.557,116.855c-.599.344-1.143.932-1.532,1.609l4.063,2.341c.388-.677.932-1.276,1.532-1.62l93.182-53.7847c.555-.3107,1.066-.3661,1.443-.1886Z" fill="#fff" opacity=".5"/>
<path d="M348.448,147.087l7.371,10.351-33.289-30.276-35.075,59.387v20.491c0,1.375.966,1.941,2.165,1.253l93.182-53.784c1.199-.688,2.165-2.374,2.165-3.75v-11.261l-24.819-17.861-11.7,25.45Z" fill="#fafafa" opacity=".95"/>
<path d="m352.46 97.296c4.34-2.5073 7.87-0.4771 7.87 4.5379 0 5.014-3.518 11.116-7.87 13.623-4.34 2.508-7.869 0.477-7.869-4.537 0-5.026 3.518-11.117 7.869-13.624z" fill="#fafafa" opacity=".95"/>
<path d="M383.768,66.1103c.189,0,.2.4881.2.5436v84.0941c0,1.02-.777,2.374-1.665,2.884L289.12,207.417c-.255.144-.411.166-.466.166-.189,0-.2-.488-.2-.544v-84.093c0-1.021.777-2.374,1.665-2.885l93.183-53.7843c.255-.1442.41-.1664.466-.1664Zm0-.9985c-.289,0-.622.0999-.966.2996L289.62,119.196c-1.199.688-2.165,2.374-2.165,3.75v84.093c0,.977.488,1.543,1.199,1.543.288,0,.621-.1.966-.3l93.182-53.785c1.199-.687,2.165-2.374,2.165-3.749v-84.0941c0-.9763-.489-1.5421-1.199-1.5421Z" fill="#096dd9"/>
<path d="m418.53 94.4 2.609-2.8845-24.564-35.046-23.998 13.169v4.7483l22.599-12.137 23.354 32.151z" fill="#d9d9d9"/>
<path d="m372.58 74.386-7.593-4.3822v-4.7483l7.593 4.3822v4.7483z" fill="#d9d9d9"/>
<path d="m364.99 65.256 23.987-13.18 7.603 4.3933-23.997 13.169-7.593-4.3822z" fill="#f0f0f0"/>
</g>
<path d="m492.32 100.76c-6.294 5.248-10.256 16.031-8.858 24.097 1.399 8.065 7.637 10.351 13.919 5.114 6.294-5.247 10.257-16.031 8.858-24.096-1.398-8.0659-7.637-10.351-13.919-5.115z" fill="#1890ff"/>
<path d="M489.405,119.895c.166.965.477,1.764.91,2.363c1.243,1.819,3.496,1.964,5.761.089c2.996-2.508,4.895-7.666,4.229-11.527-.433-2.507-1.821-3.839-3.575-3.772-.965.011-2.009.444-3.074,1.331-3.03,2.518-4.929,7.677-4.251,11.516Z" fill="#096dd9"/>
<path d="M489.405,119.895c.166.965.477,1.764.91,2.363c1.243,1.819,3.496,1.964,5.761.089c2.996-2.508,4.895-7.666,4.229-11.527-.433-2.507-1.821-3.839-3.575-3.772-.965.011-2.009.444-3.074,1.331-3.03,2.518-4.929,7.677-4.251,11.516Z" opacity=".3"/>
<path d="M489.405,119.895c.166.965.477,1.764.91,2.363.965-.011,2.02-.444,3.086-1.342c2.996-2.508,4.895-7.667,4.229-11.505-.167-.965-.478-1.764-.911-2.363-.965.011-2.009.444-3.074,1.331-3.019,2.519-4.917,7.677-4.24,11.516Z" fill="#455a64"/>
<path d="M221.784,239.834l-30.769,83.683-.011.011c-.067.211-.244.411-.544.577-.777.444-2.031.444-2.808,0-.333-.188-.522-.432-.577-.688-.011-.077-.011-.155,0-.233.011-.188.044-.377.111-.576l30.846-84.15c.377-1.031,1.521-1.564,2.553-1.176c1.054.389,1.576,1.52,1.199,2.552Z" fill="#d9d9d9"/>
<path d="m181.77 294.02-2.22 3.843 30.846 17.801 2.22-3.843-30.846-17.801z" fill="#d9d9d9"/>
<path d="m204.2 281.07-2.22 3.843 30.847 17.801 2.22-3.843-30.847-17.801z" fill="#91d5ff"/>
<path d="m204.2 281.07-2.22 3.843 30.847 17.801 2.22-3.843-30.847-17.801z" opacity=".6"/>
<path d="M196.688,234.087l16.449-9.497c1.343-.776,2.853-.887,4.13-.321c1.276.565,2.197,1.764,2.519,3.272l27.317,127.716c.022.089.033.178.033.267.011.088.011.188.011.277-.044.244-.233.488-.577.688-.777.444-2.031.444-2.808,0-.289-.178-.466-.377-.544-.588-.011-.044-.022-.078-.022-.122L215.901,228.374c-.066-.278-.177-.444-.255-.489-.078-.033-.278-.011-.522.134l-16.461,9.496" fill="#d9d9d9"/>
<path d="M210.496,295.205l-10.024-5.781c-.333-.188-.599-.654-.599-1.031v-.244c0-.378.266-.843.599-1.032l24.043-13.879c.333-.188.865-.188,1.198,0l10.023,5.78c.333.189.6.655.6,1.032v.233c0,.377-.267.843-.6,1.032l-24.042,13.878c-.333.2-.865.2-1.198.012Z" fill="#f5f5f5"/>
<path d="M211.096,293.929v1.42c.222,0,.433-.044.599-.144l12.021-6.934l12.021-6.934c.333-.189.6-.654.6-1.032v-.233c0-.188-.067-.399-.178-.588l-25.063,14.445Z" opacity=".15"/>
<path d="M200.051,287.561c-.111.188-.178.399-.178.588v.244c0,.377.266.843.599,1.031l10.024,5.78c.166.1.377.145.599.145v-1.42l-11.044-6.368Z" opacity=".1"/>
<path d="M206.155,274.891l-16.649-9.607c-.333-.189-.6-.655-.6-1.032v-.233c0-.377.267-.843.6-1.032l24.042-13.879c.333-.188.866-.188,1.199,0l16.649,9.608c.333.188.6.654.6,1.032v.233c0,.377-.267.843-.6,1.031l-24.042,13.879c-.333.2-.866.2-1.199,0Z" fill="#f5f5f5"/>
<path d="M206.754,273.626v1.42c.222,0,.433-.044.599-.144l12.021-6.934l12.021-6.934c.333-.188.6-.654.6-1.031v-.233c0-.189-.067-.4-.178-.588l-25.063,14.444Z" opacity=".15"/>
<path d="M189.072,263.42c-.111.189-.177.399-.177.588v.233c0,.377.266.843.599,1.032l16.65,9.607c.166.1.377.144.599.144v-1.42L189.072,263.42Z" opacity=".1"/>
<path d="M223.537,356.134l-10.024-5.78c-.333-.189-.599-.655-.599-1.032v-.233c0-.377.266-.843.599-1.032l24.043-13.878c.333-.189.865-.189,1.198,0l10.024,5.78c.333.188.599.654.599,1.031v.233c0,.378-.266.844-.599,1.032l-24.043,13.879c-.333.189-.877.189-1.198,0Z" fill="#f5f5f5"/>
<path d="M213.092,348.49c-.111.189-.178.4-.178.588v.233c0,.377.266.843.599,1.032l10.024,5.78c.166.1.377.144.599.144v-1.42l-11.044-6.357Z" opacity=".1"/>
<path d="M224.137,354.858v1.42c.211,0,.433-.044.599-.144l12.021-6.934l12.021-6.934c.333-.188.6-.654.6-1.031v-.233c0-.189-.067-.4-.178-.588l-25.063,14.444Z" opacity=".15"/>
<path d="M219.187,335.82l-10.023-5.78c-.333-.188-.6-.654-.6-1.031v-.233c0-.378.267-.843.6-1.032l24.042-13.879c.333-.189.866-.189,1.199,0l10.023,5.78c.333.189.599.655.599,1.032v.233c0,.377-.266.843-.599,1.032L220.386,335.82c-.333.189-.866.189-1.199,0Z" fill="#f5f5f5"/>
<path d="M208.742,328.177c-.111.188-.178.399-.178.588v.233c0,.377.267.843.6,1.032l10.023,5.78c.166.099.377.144.599.144v-1.42l-11.044-6.357Z" opacity=".1"/>
<path d="M219.787,334.545v1.42c.211,0,.433-.045.6-.144l12.021-6.934l12.021-6.934c.333-.189.599-.655.599-1.032v-.233c0-.189-.067-.399-.178-.588l-25.063,14.445Z" opacity=".15"/>
<path d="M214.845,315.518l-10.023-5.78c-.333-.189-.599-.655-.599-1.032v-.233c0-.377.266-.843.599-1.032l24.042-13.879c.333-.188.866-.188,1.199,0l10.023,5.78c.333.189.599.655.599,1.032v.233c0,.377-.266.843-.599,1.032l-24.042,13.879c-.333.188-.866.188-1.199,0Z" fill="#f5f5f5"/>
<path d="M204.4,307.863c-.111.188-.177.399-.177.588v.233c0,.377.266.843.599,1.032l5.006,2.895l5.006,2.896c.167.1.377.144.599.144v-1.42L204.4,307.863Z" opacity=".1"/>
<path d="M215.445,314.242v1.42c.211,0,.433-.044.6-.144l12.021-6.934l12.021-6.934c.333-.188.599-.654.599-1.032v-.233c0-.188-.066-.399-.177-.588l-25.064,14.445Z" opacity=".15"/>
<path d="m240.38 304.8-0.844-8.853-7.514 0.444s2.553 9.696 2.974 10.594c1.31 0.178 5.384-2.185 5.384-2.185z" fill="#ffa8a7"/>
<path d="M242.718,307.863c.056.322.034.666.012.987-.078,1.143-.145,2.297-.222,3.439-.034.466-.067.899-.167,1.354-.111.51-.033,1.109-.044,1.631-.011.566-.011,1.142-.011,1.708c0,.222-.045.511.011.732.066.233-3.586,2.386-5.062,3.218-.089.044-.166.089-.266.133-1.343.544-2.409-.533-2.409-.533-1.077-2.751.944-4.515.944-4.515s.188.011.133-1.442c-.011-1.032-.033-2.075-.144-3.107-.089-.754-.245-1.519-.389-2.263-.144-.787-.211-1.464-.433-2.518-.055-.266-.166-.544-.055-.799.077-.166.222-.299.366-.41c1.41-1.099,2.975-1.764,4.773-1.809.2-.011.422,0,.566.133.133.111.178.289.222.444.1.377.155.899.366,1.232.156.255.444.51.666.699.422.388.921.876,1.088,1.442.022.089.044.166.055.244Z" fill="#bae7ff"/>
<path d="M243.44,308.229c.688.255.533,2.13.122,3.938-.411,1.809-.633,2.441-.4,3.939.233,1.497.367,2.163-.233,2.729-.599.554-5.439,2.884-5.439,2.884s.933-2.318,1.021-5.647c.089-3.328.289-5.469,1.81-7c1.52-1.531,3.119-.843,3.119-.843Z" fill="#263238"/>
<path d="M231.263,318.835c0,0,1.909,1.498,4.084,1.72c2.176.222,2.276-.611,2.476-3.195.199-2.574.244-6.136,1.232-7.711.999-1.575,2.686-2.296,3.363-2.141.133-.078.81.41,1.032.71c0,0-1.487-.166-2.83,1.176s-1.554,4.138-1.587,6.69c-.034,2.54-.111,5.491-2.132,5.824-2.02.333-4.184-.51-5.538-1.608-.789-.633-.1-1.465-.1-1.465Z" fill="#37474f"/>
<path d="M235.503,316.017c-.177.233-.333.466-.466.699-.644,1.131-.71,2.318-.388,3.727-2.598-.199-3.375-1.608-3.375-1.608s-.155-1.287,1.321-2.252c.189-.122.377-.222.555-.3.011,0,.022-.011.022-.011c1.243-.544,2.331-.255,2.331-.255Z" fill="#91d5ff"/>
<path d="M235.646,315.074c0,0-.267.177-.3.477-.022.288.1.965.755,1.331.277.155.511.089.067-.444-.178-.21-.322-.366-.4-.61-.122-.355-.122-.754-.122-.754Z" fill="#263238"/>
<path d="M235.602,313.243c0,0-.199.2-.199.433-.012.233.432,1.187.91,1.387.477.21.222-.278-.011-.511-.122-.133-.356-.488-.5-.787-.133-.256-.2-.522-.2-.522Z" fill="#263238"/>
<path d="M235.458,311.269c0,0-.178.188-.111.454.044.2.855.766,1.154.81.211.034.378-.122,0-.377-.188-.122-.499-.299-.71-.488-.222-.189-.333-.399-.333-.399Z" fill="#263238"/>
<path d="M240.087,299.332c0,0-3.752-.5-6.971,1.841c0,0-.688-1.864-4.118-14.012-1.265-4.493-2.098-8.298-2.852-11.172-.844-3.206-1.144-4.482-1.321-6.756-.6-7.699-.788-30.165-1.11-33.06c0,0,15.984-4.882,15.995-4.849.754,3.473,1.354,7.134-.023,13.835-1.332,9.385-2.508,19.659-2.941,24.718c0,0,1.443,2.984,2.142,7.788.433,2.973,1.199,21.667,1.199,21.667Z" fill="#096dd9"/>
<path d="M205.645,307.03c-.299-.055-.144,1.01-.066,1.398.078.389.666,1.398,2.819,2.031c2.154.643,4.129.244,5.506.565c1.265.289,3.685,1.831,4.961,2.153c1.676.432,3.652.099,4.585-.411.355-.2,1.098-.721,1.121-1.076.022-.422.055-.888-.045-.888l-18.881-3.772Z" fill="#37474f"/>
<path d="M224.525,310.803c-.444.654-1.365,1.153-2.12,1.386-1.21.378-2.486.378-3.718.045-.722-.2-1.554-.633-2.209-.999-.777-.421-1.587-.632-2.431-.887-1.077-.322-3.152-.333-4.273-.455s-1.732-.333-2.642-.999c-.688-.51-.777-1.597-.81-2.474-.011-.188-.011-.466.066-.632.1-.2.233-.233.433-.344.444-.266.833-.621,1.299-.854.633-.322,3.13-1.087,4.151-.732l.544.122c.422.088,2.065.266,2.497.277.433.011.888-.078,1.233-.333.288-.222.499-.566.821-.732.355-.178.788-.122,1.177-.011.721.188,1.309.299,2.031.033.777-.288,1.465-.987,2.231-1.309.211-.089.51-.1.744.144.199.211.199.588.177.877-.066,1.009.156,2.008.267,3.017.166,1.376.987,2.874.532,4.86Z" fill="#bae7ff"/>
<path d="M213.704,303.88c-.455-.056-1.021.299-1.277.677-.155.233-.255.399-.355.665-.111.322-.189.666-.111.999.022.088.111.144.189.133.077-.011.155-.089.166-.167.089-.466.178-.887.422-1.287.111-.177.189-.332.355-.466.156-.122.344-.221.544-.277.056-.011.144-.044.255-.033.045,0,.134.011.134.011c0-.1-.078-.155-.111-.166-.067-.067-.111-.078-.211-.089Z" fill="#263238"/>
<path d="M215.103,304.08c-.233-.045-.488,0-.699.133-.244.155-.344.388-.466.643-.145.3-.256.61-.289.943-.022.167-.078.832.244.755.078-.023.133-.089.167-.156.033-.077.044-.155.055-.233.045-.344.122-.676.255-.998.145-.366.422-.832.844-.899.044-.011.1-.011.144-.011.011-.078-.077-.122-.144-.144-.044-.011-.078-.022-.111-.033Z" fill="#263238"/>
<path d="M216.324,303.758c-.177.044-.344.122-.466.255-.111.122-.244.344-.3.511-.133.355-.222.621-.244,1.042-.022.267,0,.544.122.788.045.1.145.2.245.155.111-.044.111-.21.133-.299.033-.178.033-.366.055-.544.067-.665.344-1.287.777-1.786.022-.022.056-.078.056-.078-.011-.044-.1-.066-.145-.066-.077-.011-.155,0-.233.022Z" fill="#263238"/>
<path d="M212.271,303.847c-.51-.089-1.72.443-2.508,1.941s-.888,3.972-.888,3.972-1.343-.078-2.609-1.509c-.333-.377-.566-.843-.655-1.342.034-1.065.4-1.809.911-2.252.899-.777,1.809-1.276,4.129-1.232c2.086.033,2.153.544,2.153.544l-.533-.122Z" fill="#91d5ff"/>
<path d="M222.816,296.402c0,0,.266,4.726.455,5.991.1.71-.411,1.454-2.531,2.086-1.487.444-3.063-.011-3.085-.422l-1.01-4.493l6.171-3.162Z" fill="#ffa8a7"/>
<path d="M240,232.378l-25.707,2.663c0,0-5.594,33.637-5.728,37.232-.1,2.607,7.781,27.89,7.781,27.89s4.884.666,6.927-1.753c-.222-3.394-.589-12.004-.822-15.099-.388-4.981-.766-6.9-2.175-11.826c0,0,6.893-17.165,9.224-24.986c11.566-4.371,10.5-14.121,10.5-14.121Z" fill="#0050b3"/>
<path d="M240,232.378l-25.707,2.663c0,0-5.594,33.637-5.728,37.232-.1,2.607,7.781,27.89,7.781,27.89s4.884.666,6.927-1.753c-.222-3.394-.589-12.004-.822-15.099-.388-4.981-.766-6.9-2.175-11.826c0,0,5.994-17.418,8.325-25.239C240.167,241.875,240,232.378,240,232.378Z" fill="#096dd9"/>
<g transform="translate(2e-6)">
<path d="m221.23 191.57 1.065 9.175 9.835-1.431-1.044-9.896-9.856 2.152z" fill="#ffa8a7"/>
<path d="m214.95 179.68s-2.264 4.36-2.009 4.682 1.909 0.843 1.909 0.843l0.1-5.525z" fill="#f28f8f"/>
<path d="M215.979,182.455c-1.465,0-2.653-1.154-2.653-2.574s1.188-2.574,2.653-2.574s2.653,1.154,2.653,2.574-1.188,2.574-2.653,2.574Zm0-4.582c-1.143,0-2.065.899-2.065,2.008s.922,2.008,2.065,2.008s2.065-.899,2.065-2.008-.933-2.008-2.065-2.008Z" fill="#263238"/>
<path d="M221.129,171.261c-4.517.921-5.816,2.851-6.238,9.807-.444,7.267.111,8.82,1.088,9.796.666.666,3.563.977,5.306.899c6.316-.299,8.724-2.319,11.2-5.98c2.897-4.304,3.662-10.129.555-12.514-4.385-3.361-10.079-2.385-11.911-2.008Z" fill="#ffa8a7"/>
<path d="M215.979,190.864c.666.666,3.563.977,5.305.899l-.133-1.021c0,0-2.719.322-5.172.122Z" fill="#f28f8f"/>
<path d="M213.57,179.337l8.025,1.01-.321.544-7.704-.966v-.588Z" fill="#263238"/>
<path d="M214.979,173.48c.211.133.433.233.678.299-.189.855.088,1.809.71,2.43s1.565.899,2.431.71c-.189.499-.133,1.076.133,1.531s.755.777,1.276.854c-.144-.022-.022,1.221.012,1.331.133.5.321.722.788.921c0,0,.71-2.684,3.052-2.352c2.353.344,2.664,3.24,1.188,5.004-1.477,1.753-2.842,1.098-2.864,1.087.133.067.189.921.233,1.087.144.499.344,1.454.6,1.92.51.943.643,1.653,2.375,2.418.843.377,4.728-.066,5.539-1.009c1.376-3.462,3.318-4.749,4.861-7.822.7-1.398,1.499-3.029,1.577-4.615.066-1.376.099-2.851-1.732-4.227.533-.621.61-1.586.178-2.296-.422-.699-1.321-1.088-2.12-.91.233-1.243-.4-2.596-1.488-3.229-.688-.399-1.532-.499-2.298-.31-.222.055-1.942.754-1.886,1.043-.234-1.276-1.421-2.297-2.72-2.352-1.299-.045-2.553.898-2.875,2.152-.644-.987-1.687-1.72-2.841-1.986-.999-.233-2.131-.1-2.875.599s-.855,2.23-.311,3.251c-.777-.067-1.532.144-2.076.81-.466.577-.71,1.264-.599,2.008.1.787.4,1.242,1.054,1.653Z" fill="#263238"/>
<path d="M220.952,183.098l.078-3.039c-.866-.023-1.588.643-1.61,1.475-.011.843.666,1.542,1.532,1.564Z" fill="#263238"/>
</g>
<path d="M234.947,194.547c2.742-.288,5.239.244,6.705,2.896c1.465,2.64,3.307,12.27,4.029,16.63.677,4.105.988,4.859-1.232,6.301-1.465.966-7.57,4.127-7.57,4.127l-1.932-29.954Z" fill="#ffa8a7"/>
<path d="M233.783,194.37c2.997-.344,5.328-.222,6.46.81c1.132,1.031,2.331,2.085,3.142,6.079.81,3.994,1.875,9.697,1.875,9.697s-2.22,4.67-10.311,4.016l-1.166-20.602Z" fill="#91d5ff"/>
<path d="M233.783,194.37c2.997-.344,5.328-.222,6.46.81c1.132,1.031,2.331,2.085,3.142,6.079.81,3.994,1.875,9.697,1.875,9.697s-2.22,4.67-10.311,4.016l-1.166-20.602Z" fill="#e6f7ff" opacity=".3"/>
<path d="M239.776,199.007c-.455-2.474-3.707-5.081-6.759-4.582c0,0-.722.056-1.388.178-.344,1.486-6.871,3.162-9.845,1.808-1.177.311-4.218.899-5.195,1.22-2.797.932-3.952,3.684-4.396,12.814c0,0,1.732,20.868,1.743,26.715c4.729,1.353,10.778,1.586,16.583.288c8.347-1.863,9.479-5.07,9.479-5.07l-.799-9.585c-.011-.011,1.599-18.183.577-23.786Z" fill="#f5f5f5"/>
<g transform="translate(1e-6 5e-6)">
<g transform="translate(1e-6)">
<path d="M177.906,186.382l1.998,11.727c.045.233.233.432.555.554.666.244,1.643.078,2.176-.377.266-.222.388-.477.344-.71l-1.998-11.726-3.075.532Z" fill="#455a64"/>
<path d="M181.514,188.878l-2.786,2.297-.822-4.793l3.086-.521.522,3.017Z" opacity=".15"/>
<path d="M180.936,176.187l.41,2.385l1.865-1.542.844,4.903c.255,1.476-.478,3.451-1.632,4.405l-3.274,2.718c-.544.444-1.088.599-1.532.466-.045-.011-1.998-.721-2.42-.899-.433-.189-.755-.643-.877-1.353l-.843-4.904l7.459-6.179Z" fill="#37474f"/>
<path d="M179.161,189.056l3.274-2.718c1.155-.954,1.887-2.929,1.632-4.416l-.844-4.903-7.448,6.179.844,4.904c.244,1.497,1.387,1.919,2.542.954Z" fill="#455a64"/>
<path d="m175.76 183.21 7.448-6.179-1.221-7.156-7.459 6.179 1.232 7.156z" fill="#f0f0f0"/>
<path d="m175.76 183.21-1.232-7.155-2.264-0.844 1.221 7.156 2.275 0.843z" fill="#ebebeb"/>
<path d="M176.131,185.339l7.448-6.179.166.976-7.437,6.224-.177-1.021Z" fill="#fafafa"/>
<path d="M176.131,185.339l-2.276-.843.178,1.032l2.276.832-.178-1.021Z" fill="#e6e6e6"/>
<path d="m172.65 177.4s0.987 1.586 2.375 1.442l-0.477-2.784-2.265-0.844 0.367 2.186z" fill="#91d5ff"/>
<path d="m172.65 177.4s0.987 1.586 2.375 1.442l-0.477-2.784-2.265-0.844 0.367 2.186z" fill="#096dd9" opacity=".5"/>
<path d="M175.01,178.849c0,0,1.587.41,2.753-.81.865-.899.754-3.528,2.641-3.894c1.332-.266,2.476.987,2.476.987l-.899-5.247-7.46,6.179.489,2.785Z" fill="#1890ff"/>
<path d="m181.99 169.87-2.275-0.843-7.448 6.179 2.264 0.843 7.459-6.179z" fill="#91d5ff"/>
<path d="m181.99 169.87-2.275-0.843-7.448 6.179 2.264 0.843 7.459-6.179z" fill="#69c0ff" opacity=".7"/>
</g>
<g transform="translate(0 1e-6)">
<path d="M181.957,191.497c1.121.066,2.12,1.087,2.153,1.863.034.777-1.631,1.332-1.631,1.332l-.522-3.195Z" fill="#f28f8f"/>
<path d="M216.81,197.565c-1.887.4-2.109-.144-7.17,2.918-4.706,2.851-12.898,7.555-15.962,9.308-3.274-2.995-6.77-7.633-8.047-9.874-.344-.599-.477-1.52-.411-2.208.178-1.919-.244-2.096-.31-3.827-.045-1.342.333-2.075.022-2.208-.4-.166-1.321-.133-1.865.921-.277.532-.366,1.021-.588,2.097c0,0-2.853,2.54-3.153-.011l-.555-3.218c-.865,0-2.475.278-2.886,2.219-.355,1.687-.588,5.37.666,7.733.6,1.131,3.142,2.086,4.54,3.006c1.565,1.032,6.327,11.228,9.246,13.191c1.799,1.176,2.964.966,5.284.178c5.206-1.764,17.926-8.121,17.926-8.121s5.317-3.162,3.263-12.104Z" fill="#ffa8a7"/>
<path d="m217.29 197.44c-4.118-0.089-6.438 2.086-10.945 4.693-4.795 2.762-6.482 3.739-6.482 3.739s-1.265 6.257 3.53 9.108l10.855-5.048c3.042-1.964 5.184-7.422 3.042-12.492z" fill="#91d5ff"/>
<path d="m217.29 197.44c-4.118-0.089-6.438 2.086-10.945 4.693-4.795 2.762-6.482 3.739-6.482 3.739s-1.265 6.257 3.53 9.108l10.855-5.048c3.042-1.964 5.184-7.422 3.042-12.492z" fill="#e6f7ff" opacity=".3"/>
</g>
</g>
<path d="M199.351,252.781l-30.736,83.583-.011.022-.066.189c-.089.178-.256.333-.5.488-.777.444-2.031.444-2.808,0-.333-.2-.522-.444-.577-.688-.011-.077-.011-.155,0-.233.011-.199.044-.388.122-.577l30.846-84.149c.378-1.031,1.51-1.553,2.553-1.176c1.033.366,1.554,1.509,1.177,2.541Z" fill="#d9d9d9"/>
<path d="M198.662,237.515c-.91.533-1.543,1.942-1.321,2.963l27.317,127.704c.022.1.044.211.044.311.012.067.012.144,0,.222-.044.244-.233.499-.577.699-.777.444-2.031.444-2.808,0-.289-.167-.466-.366-.544-.577c0-.011,0-.011-.011-.022l-.033-.167L193.445,241.31c-.577-2.685.833-5.869,3.219-7.234" fill="#d9d9d9"/>
<path d="M157.75,345.983h-28.859l1.298,20.502c-.022,1.908,1.221,3.816,3.719,5.303c4.995,2.962,13.153,3.051,18.226.2c2.553-1.443,3.829-3.351,3.829-5.27l1.787-20.735Z" fill="#bae7ff"/>
<path d="M133.163,339.604c-5.672,3.195-5.727,8.465-.122,11.782c5.606,3.317,14.741,3.417,20.424.222c5.672-3.196,5.727-8.465.122-11.782-5.617-3.318-14.752-3.417-20.424-.222Z" fill="#455a64"/>
<path d="M156.495,345.739c0,.377-.078.754-.2,1.131-.444,1.354-1.621,2.608-3.452,3.639-2.564,1.432-5.972,2.197-9.612,2.164-3.63-.045-7.027-.877-9.557-2.374-1.776-1.054-2.942-2.319-3.341-3.639-.145-.389-.2-.799-.2-1.21.033-1.764,1.321-3.45,3.663-4.759c2.542-1.442,5.971-2.219,9.601-2.163c3.641.033,7.037.876,9.568,2.374c2.287,1.375,3.552,3.084,3.53,4.837Z" fill="#e6f7ff"/>
<path d="M143.375,348.024c-3.629-.033-7.048.743-9.601,2.186-.045.022-.089.055-.133.077.011.011.022.011.033.022c2.531,1.487,5.927,2.319,9.557,2.375c3.641.033,7.048-.744,9.612-2.164.045-.022.089-.055.133-.089-.011-.011-.022-.011-.033-.022-2.531-1.486-5.916-2.33-9.568-2.385Z" fill="#1890ff"/>
<path d="M122.875,366.524c-3.629-.033-1.322,1.034-3.875,2.476c6.226,1.291,0-1.5,0-1.5c1.259-.629-4-1.264-2,1.21s2.101,2.418,5.731,2.474c3.641.033.705.236,3.269-1.184v-1.29-.71c-2.531-1.487.527-1.42-3.125-1.476Z" fill="#1890ff"/>
<path d="M144.308,352.662c-.977-.211-1.876-.544-2.631-.988-.944-.566-1.565-1.242-1.787-1.941-.078-.211-.111-.433-.111-.644c0-.288.078-.566.211-.843-2.331.322-4.485.976-6.216,1.964-.045.022-.089.055-.133.077.011.012.022.012.033.023c2.531,1.486,5.927,2.318,9.557,2.374.355,0,.721-.011,1.077-.022Z" opacity=".15"/>
<path d="M148.792,353.693c.566-.322,1.021-.055,1.01.599-.011.644-.466,1.431-1.032,1.753s-1.021.056-1.01-.599.466-1.431,1.032-1.753Z" fill="#263238"/>
<path d="M129.223,351.718c-1.244,3.184-2.209,7.522.577,9.586.155.111.3.21.444.31l-.011,1.343c-.367-.222-.744-.477-1.11-.744-4.14-3.062-1.61-9.774-.022-13.035l.122,2.54Z" fill="#455a64"/>
<path d="M149.269,355.135c-.155.322-4.14,7.933-10.678,9.464-.755.177-1.498.266-2.253.266-2.031,0-4.063-.633-6.094-1.908l.011-1.343c2.709,1.875,5.428,2.519,8.092,1.908c6.038-1.42,9.912-8.831,9.934-8.897.145-.277.489-.377.755-.233.278.122.389.455.233.743Z" fill="#455a64"/>
<path d="M142.5,357.5c.931-1.392.206-4.897-1.5-5-1.752-.105-3.064,3.562-2.103,5c.846,1.265,2.758,1.265,3.603,0Z" fill="#1890ff"/>
<path d="M141.42,357.616c.93-1.392,1.375-4.423-.5-5-1.5-.461-2.984,3.446-2.023,4.884.845,1.265,1.677,1.381,2.523.116Z" fill="#147bd9"/>
<path d="M292.228,454.049l-44.963-26.25c-1.02-.6-1.02-1.562,0-2.163L354.983,362.95c1.02-.601,2.689-.601,3.709,0l45.043,26.21c1.02.6,1.02,1.562,0,2.163L295.924,454.049c-1.007.601-2.663.601-3.696,0Z" fill="#fafafa"/>
<path d="M350.303,376.467l-5.134,2.965c-.519.292-1.344.292-1.862,0l-1.636-.944c-.519-.293-.838-1.011-.705-1.596l17.038-80.613c.119-.585.638-1.29,1.157-1.596l5.134-2.964c.519-.293,1.343-.293,1.862,0l1.636.944c.519.292.838,1.01.705,1.595L351.46,374.872c-.133.585-.652,1.303-1.157,1.595Z" fill="#f0f0f0"/>
<path d="M350.303,376.467l-5.134,2.965c-.519.292-.838.066-.705-.519L361.502,298.3c.12-.585.638-1.29,1.157-1.596l5.134-2.964c.519-.293.838-.067.705.518L351.46,374.872c-.133.585-.652,1.303-1.157,1.595Z" fill="#d9d9d9"/>
<path d="m347.57 371.2 1.517-7.192 3.498-2.007 32.373 18.678 1.517 8.947-3.485 2.007-35.42-20.433z" fill="#d9d9d9"/>
<path d="m382.99 391.64-35.42-20.433 1.517-7.192 32.373 18.678 1.53 8.947z" fill="#f0f0f0"/>
<path d="M388.756,398.655l-5.134,2.965c-.519.292-1.344.292-1.862,0l-1.636-.944c-.519-.293-1.011-1.011-1.117-1.596L361.889,298.313c-.093-.585.239-1.302.758-1.595l5.134-2.964c.519-.293,1.343-.293,1.862,0l1.636.943c.519.293,1.011,1.011,1.117,1.596L389.514,397.06c.093.585-.239,1.303-.758,1.595Z" fill="#37474f"/>
<path d="M361.902,298.3l.652,3.815l16.479,96.965c.107.585.599,1.303,1.104,1.596l1.636.944c.519.292,1.357.292,1.862,0l5.121-2.965c.519-.306.851-1.01.758-1.595L372.449,296.638l-.066-.345c-.106-.585-.599-1.303-1.104-1.596l-1.636-.943c-.519-.293-1.357-.293-1.862,0l-5.134,2.964c-.505.293-.851.997-.745,1.582Z" fill="#d9d9d9"/>
<path d="M372.384,296.292L389.502,397.06c.093.584-.239,1.302-.758,1.595l-5.134,2.964c-.519.293-1.011.067-1.117-.518L365.375,300.32c-.093-.585.239-1.302.758-1.595l5.134-2.964c.519-.293,1.024-.054,1.117.531Z" fill="#f0f0f0"/>
<path d="M269.867,422.876l-5.134,2.964c-.519.293-1.343.293-1.862,0l-1.636-.944c-.519-.292-.838-1.01-.705-1.595l17.038-80.614c.12-.585.639-1.289,1.157-1.595l5.134-2.964c.519-.293,1.344-.293,1.862,0l1.636.943c.519.293.838,1.011.705,1.596L271.024,421.28c-.119.585-.638,1.303-1.157,1.596Z" fill="#f0f0f0"/>
<path d="M269.867,422.876l-5.134,2.964c-.519.293-.838.067-.705-.518l17.038-80.614c.12-.585.639-1.29,1.157-1.595l5.134-2.965c.519-.292.838-.066.705.519L271.024,421.28c-.119.585-.638,1.303-1.157,1.596Z" fill="#d9d9d9"/>
<path d="m267.05 417.73 1.516-7.192 3.498-2.008 32.374 18.678 1.516 8.947-3.485 2.021-35.419-20.446z" fill="#d9d9d9"/>
<path d="m302.47 438.18-35.419-20.445 1.516-7.192 32.387 18.691 1.516 8.946z" fill="#f0f0f0"/>
<path d="M308.33,445.077l-5.134,2.964c-.519.293-1.343.293-1.862,0l-1.636-.943c-.519-.293-1.011-1.011-1.117-1.596L281.463,344.735c-.093-.585.239-1.303.758-1.595l5.134-2.965c.519-.292,1.343-.292,1.862,0l1.636.944c.519.293,1.011,1.011,1.117,1.596l17.118,100.767c.093.571-.239,1.289-.758,1.595Z" fill="#37474f"/>
<path d="M281.476,344.722l.652,3.815l16.479,96.965c.107.585.599,1.303,1.104,1.595l1.636.944c.519.293,1.357.293,1.862,0l5.121-2.964c.519-.306.851-1.011.758-1.596L292.024,343.047l-.067-.346c-.106-.585-.598-1.303-1.104-1.595l-1.636-.944c-.518-.293-1.356-.293-1.862,0l-5.134,2.964c-.505.293-.851,1.011-.745,1.596Z" fill="#d9d9d9"/>
<path d="M291.959,342.701l17.117,100.767c.094.585-.239,1.303-.758,1.595l-5.134,2.965c-.518.292-1.011.066-1.117-.519L284.949,346.742c-.093-.585.24-1.303.758-1.595l5.134-2.965c.519-.306,1.024-.066,1.118.519Z" fill="#f0f0f0"/>
<path d="m277.79 357.78 108.39-62.535-1.769-1.023-108.37 62.547 1.756 1.011z" fill="#ff7875"/>
<path d="m277.79 357.78 108.39-62.535-1.769-1.023-108.37 62.547 1.756 1.011z" fill="#fff" opacity=".4"/>
<path d="m277.79 357.77 5.267 31.28 108.39-62.534-5.267-31.28-108.39 62.534z" fill="#ff7875"/>
<path d="m276.03 356.76 5.254 31.281 1.769 1.01-5.267-31.28-1.756-1.011z" fill="#d9d9d9"/>
<path d="m277.12 363.28 2.913 17.309 1.756 1.01-2.9-17.295-1.769-1.024z" fill="#cf1322"/>
<path d="m277.12 363.28 2.913 17.309 1.756 1.01-2.9-17.295" opacity=".2"/>
<path d="m287.74 352.04-4.682 37.01-5.267-31.28 9.949-5.73z" fill="#ebebeb"/>
<path d="m381.49 332.25 4.682-37.01 5.267 31.281-9.949 5.729z" fill="#ebebeb"/>
<path d="m301.25 378.55 4.682-37.01-10.76 6.195-4.682 37.024 10.76-6.209z" fill="#ebebeb"/>
<path d="m319.45 368.05 4.681-37.01-10.76 6.194-4.681 37.024 10.76-6.208z" fill="#ebebeb"/>
<path d="m337.66 357.55 4.681-37.01-10.76 6.208-4.682 37.01 10.761-6.208z" fill="#ebebeb"/>
<path d="m355.85 347.06 4.682-37.023-10.76 6.208-4.682 37.01 10.76-6.195z" fill="#ebebeb"/>
<path d="m374.06 336.56 4.682-37.024-10.76 6.208-4.682 37.01 10.76-6.194z" fill="#ebebeb"/>
<path d="m295.17 347.74-1.769-1.01 10.774-6.208 1.755 1.023-10.76 6.195z" fill="#fafafa"/>
<path d="m313.37 337.24-1.756-1.01 10.76-6.208 1.756 1.023-10.76 6.195z" fill="#fafafa"/>
<path d="m331.58 326.75-1.769-1.023 10.773-6.208 1.756 1.023-10.76 6.208z" fill="#fafafa"/>
<path d="m349.77 316.24-1.755-1.024 10.76-6.208 1.755 1.024-10.76 6.208z" fill="#fafafa"/>
<path d="m367.98 305.74-1.769-1.024 10.773-6.208 1.756 1.024-10.76 6.208z" fill="#fafafa"/>
<path d="m277.79 357.77-1.756-1.01 9.936-5.743 1.769 1.024-9.949 5.729z" fill="#fafafa"/>
<path d="m278.9 364.3 2.899 17.296 108.39-62.548-2.899-17.295-108.39 62.547z" fill="#cf1322"/>
<path d="M284.171,375.797c-.626-.307-1.049-1.003-1.254-2.09l-1.419-7.497c-.063-.345.143-.641.603-.916l.488-.276c.46-.262.729-.227.792.117l1.387,7.339c.079.437.241.74.484.908s.511.162.832-.017c.306-.179.527-.449.66-.837.133-.389.167-.802.074-1.225l-1.387-7.339c-.063-.344.128-.641.603-.916l.474-.262c.46-.262.729-.227.792.117l1.419,7.498c.205,1.086.111,2.072-.28,2.97-.391.899-1.054,1.604-1.946,2.113-.935.511-1.695.619-2.322.313Z" fill="#fff"/>
<path d="M290.483,372.52l-1.955-10.319c-.063-.344.129-.641.603-.916l.307-.179c.153-.096.293-.139.406-.128.099-.002.213.076.315.22l3.155,4.019c.159.223.378.565.655,1.024l.041-.054c-.165-.515-.286-.925-.335-1.216l-.976-5.14c-.064-.344.128-.641.602-.916l.293-.165c.46-.262.729-.228.792.117l1.955,10.319c.063.344-.143.641-.603.916l-.237.138c-.153.096-.293.139-.406.141s-.227-.075-.315-.22l-3.341-4.175c-.101-.118-.276-.407-.525-.841l-.054.081c.164.462.255.806.302,1.045l1.009,5.298c.063.345-.143.655-.603.916l-.293.166c-.46.248-.729.214-.792-.131Z" fill="#fff"/>
<path d="M297.826,368.704c-.086-.065-.16-.21-.206-.435l-1.892-10.001c-.047-.225-.036-.411.017-.559.054-.147.15-.255.303-.351l2.288-1.309c1.046-.593,1.892-.57,2.569.095.676.651,1.181,1.931,1.542,3.812.362,1.894.379,3.45.065,4.666s-1.002,2.121-2.034,2.713l-2.287,1.308c-.153.11-.28.112-.365.061Zm2.188-3.421c.419-.248.678-.678.764-1.318s.011-1.596-.241-2.894c-.237-1.285-.508-2.144-.799-2.577-.29-.433-.645-.519-1.063-.271l-.767.441l1.339,7.06.767-.441Z" fill="#fff"/>
<path d="M304.758,364.736c-.086-.064-.159-.209-.206-.434l-1.892-10.001c-.046-.226-.036-.412.017-.559.054-.148.151-.256.304-.352l3.556-2.025c.126-.069.225-.058.297.047.087.091.147.29.195.568l.095.503c.048.279.066.504.027.665-.025.16-.107.268-.233.337l-2.12,1.212.441,2.331l1.883-1.074c.112-.069.225-.058.297.047.073.092.147.289.195.568l.095.503c.048.278.066.504.027.664-.039.161-.107.282-.233.351l-1.883,1.074.49,2.61l2.134-1.213c.125-.068.224-.057.297.048.072.091.147.289.194.568l.095.503c.048.278.066.504.041.664s-.093.268-.219.337l-3.556,2.025c-.111.082-.238.098-.338.033Z" fill="#fff"/>
<path d="M310.048,361.36l-1.923-10.16c-.047-.225-.036-.411.017-.558.054-.148.164-.27.331-.366l2.009-1.144c.739-.427,1.372-.519,1.913-.277.54.229.921.926,1.141,2.052.236,1.258.033,2.379-.624,3.376l.015.066c.198.023.398.152.6.374s.376.471.509.774l1.295,2.687.074.171c.062.292-.172.575-.66.864l-.419.235c-.418.247-.7.24-.804-.024l-1.225-2.702c-.116-.197-.232-.314-.345-.352-.114-.037-.268-.008-.464.103l-.292.165.693,3.643c.063.344-.143.654-.631.93l-.404.234c-.46.275-.729.254-.806-.091Zm1.403-7.447c.182-.097.305-.298.383-.606.079-.307.071-.679-.008-1.143-.08-.451-.212-.741-.37-.884s-.342-.152-.551-.042l-.642.359.505,2.689.683-.373Z" fill="#fff"/>
<path d="M318.291,355.785c-.69-.638-1.224-1.957-1.603-3.971-.363-1.934-.366-3.529-.024-4.772.356-1.257,1.099-2.19,2.214-2.837.377-.22.741-.36,1.078-.42.267-.045.44.111.503.482.079.41.101.809.052,1.182-.049.387-.157.588-.34.605-.282.046-.548.131-.799.282-.474.275-.788.774-.901,1.494-.126.734-.079,1.69.156,2.896.236,1.205.52,2.037.868,2.495.348.459.787.543,1.302.24.154-.083.279-.165.376-.26s.222-.217.388-.367c.027-.027.055-.054.097-.081.167-.097.31-.033.441.19.131.224.237.554.316.991.031.159.034.319.009.452-.025.147-.093.281-.231.43-.125.136-.291.299-.513.476-.208.177-.445.328-.682.466-1.115.673-2.018.665-2.707.027Z" fill="#fff"/>
<path d="M321.946,348.851c-.725-3.841-.042-6.368,2.064-7.567c1.032-.592,1.893-.556,2.611.068s1.251,1.93,1.629,3.917c.378,1.973.382,3.582.026,4.826s-1.043,2.148-2.075,2.74c-2.106,1.199-3.529-.129-4.255-3.984Zm4.494,1.134c.135-.322.197-.762.185-1.346-.012-.585-.111-1.341-.286-2.295-.173-.927-.357-1.642-.55-2.13s-.396-.776-.61-.892c-.214-.115-.467-.084-.76.082-.293.165-.5.409-.649.717-.134.322-.21.776-.198,1.361.011.584.097,1.341.285,2.281.174.941.357,1.642.564,2.129.193.489.397.79.624.892.214.116.482.084.761-.081.293-.153.499-.396.634-.718Z" fill="#fff"/>
<path d="M330.301,349.802l-1.954-10.319c-.063-.345.128-.641.602-.916l.307-.179c.153-.096.293-.139.406-.128.113-.002.214.076.315.22l3.155,4.019c.16.223.378.564.641,1.024l.041-.054c-.165-.515-.286-.925-.334-1.216l-.977-5.14c-.063-.344.128-.641.602-.916l.293-.165c.461-.262.729-.228.793.117l1.954,10.319c.063.344-.143.641-.603.916l-.222.138c-.154.096-.294.138-.407.141-.113.002-.227-.076-.314-.22l-3.342-4.175c-.101-.118-.276-.407-.524-.841l-.055.081c.164.462.256.806.303,1.044l1.008,5.299c.063.344-.128.654-.602.916l-.293.165c-.461.262-.73.214-.793-.13Z" fill="#fff"/>
<path d="M338.162,345.736c-.408.062-.733.015-.962-.153-.144-.131-.232-.288-.264-.474-.062-.304-.055-.637.008-1.024s.157-.615.311-.697c.069-.042.182-.044.338-.007.156.023.283.047.354.046.353.046.648-.013.885-.151.251-.138.459-.354.609-.61.15-.269.2-.589.121-.986-.062-.292-.151-.503-.281-.66-.13-.143-.287-.247-.443-.297-.17-.049-.397-.098-.694-.132-.693-.119-1.234-.348-1.608-.713s-.64-.971-.812-1.845c-.175-.967-.094-1.86.257-2.664.35-.818.916-1.441,1.683-1.869.28-.152.574-.264.897-.323.323-.06.591-.065.79.01.17.05.272.208.319.433.048.278.068.597.033.97-.035.359-.115.587-.254.67-.056.041-.183.056-.366.073-.352.021-.619.079-.828.203-.321.179-.528.396-.608.65-.079.255-.103.494-.056.733.079.397.211.66.411.776s.483.203.88.262l.311.047c.637.107,1.121.35,1.453.702.331.353.582.933.739,1.741.111.609.123,1.22.023,1.834-.101.627-.315,1.203-.643,1.741-.327.539-.784.973-1.37,1.304-.419.208-.811.349-1.233.41Z" fill="#fff"/>
<path d="M343.816,342.1l-1.639-8.69-1.339.758c-.126.069-.225.058-.298-.047-.086-.091-.146-.29-.194-.568l-.095-.503c-.048-.279-.066-.504-.027-.665.039-.16.107-.281.233-.35l4.574-2.604c.126-.069.239-.058.311.034.087.091.147.289.209.567l.095.504c.048.278.066.504.027.664s-.107.281-.247.351l-1.353.771l1.64,8.69c.063.344-.128.641-.602.916l-.489.276c-.474.275-.728.24-.806-.104Z" fill="#fff"/>
<path d="M348.156,339.621l-1.923-10.16c-.047-.226-.037-.412.017-.559.053-.148.164-.269.345-.366l2.008-1.144c.739-.427,1.386-.519,1.927-.277.541.228.921.912,1.142,2.064.236,1.259.033,2.38-.624,3.377l.015.066c.198.023.398.152.6.374s.376.484.509.787l1.295,2.687.074.171c.062.292-.172.576-.66.865l-.418.247c-.433.248-.701.24-.805-.024l-1.224-2.701c-.117-.197-.232-.315-.346-.352-.113-.038-.268-.008-.463.102l-.293.166.693,3.642c.063.345-.142.655-.63.93l-.405.234c-.488.249-.771.215-.834-.129Zm1.389-7.434c.182-.097.304-.299.383-.619.078-.308.085-.68-.009-1.13-.093-.451-.212-.754-.37-.884-.158-.143-.356-.152-.551-.042l-.642.358.519,2.689.67-.372Z" fill="#fff"/>
<path d="M355.007,335.403c-.627-.307-1.05-1.003-1.255-2.089l-1.418-7.498c-.063-.344.142-.654.602-.916l.488-.276c.461-.262.729-.227.793.117l1.387,7.339c.079.437.24.74.484.921.229.169.511.163.818-.016.306-.179.527-.449.66-.838.133-.388.168-.787.074-1.224l-1.387-7.339c-.063-.344.142-.641.602-.916l.461-.262c.46-.262.729-.227.792.117l1.419,7.498c.205,1.086.111,2.072-.28,2.971-.391.885-1.054,1.603-1.947,2.113-.893.496-1.666.604-2.293.298Z" fill="#fff"/>
<path d="M361.763,331c-.689-.638-1.223-1.957-1.602-3.971-.363-1.934-.366-3.529-.024-4.773.355-1.257,1.099-2.189,2.214-2.836.377-.22.74-.36,1.078-.42.267-.045.439.111.503.482.079.41.101.809.052,1.182-.049.387-.158.588-.341.605-.281.046-.547.131-.798.282-.474.275-.789.774-.901,1.494-.127.734-.08,1.69.156,2.895.236,1.206.52,2.038.868,2.496.347.459.786.543,1.302.24.154-.083.279-.165.376-.26s.221-.217.387-.367c.028-.027.056-.054.098-.081.167-.097.309-.033.441.19.131.224.236.554.316.991.031.159.034.319.009.452-.026.147-.094.281-.232.43-.124.136-.29.298-.512.476-.222.177-.445.328-.682.465-1.129.674-2.032.666-2.708.028Z" fill="#fff"/>
<path d="M367.999,328.31l-1.654-8.69-1.339.758c-.126.069-.225.058-.298-.047-.086-.091-.146-.29-.194-.568l-.095-.503c-.048-.279-.066-.504-.027-.665.025-.16.107-.281.233-.35l4.574-2.604c.126-.069.239-.058.311.034.087.091.147.289.195.568l.095.503c.047.278.066.504.027.664-.025.16-.107.282-.247.351l-1.353.771l1.654,8.69c.063.344-.142.641-.602.916l-.503.276c-.446.275-.714.24-.777-.104Z" fill="#fff"/>
<path d="M372.309,325.843l-1.954-10.319c-.064-.358.127-.668.602-.93l.502-.289c.46-.262.715-.213.793.144l1.954,10.319c.063.358-.128.667-.588.929l-.502.289c-.475.263-.729.214-.807-.143Z" fill="#fff"/>
<path d="M374.315,318.982c-.725-3.842-.042-6.368,2.064-7.567c1.018-.579,1.893-.556,2.611.068.704.624,1.252,1.929,1.63,3.916.377,1.974.381,3.583.025,4.826-.356,1.244-1.043,2.149-2.075,2.741-2.106,1.199-3.515-.13-4.255-3.984Zm4.508,1.133c.135-.321.197-.761.185-1.346s-.111-1.341-.285-2.295c-.174-.927-.357-1.641-.551-2.13-.193-.488-.396-.776-.61-.892-.214-.115-.467-.083-.76.082s-.499.409-.648.718c-.135.321-.211.775-.199,1.36s.097,1.341.271,2.281c.174.941.357,1.642.564,2.13.193.488.397.79.625.892.228.115.481.083.76-.082.293-.152.5-.396.648-.718Z" fill="#fff"/>
<path d="M382.672,319.933l-1.955-10.319c-.063-.344.143-.641.603-.916l.307-.179c.153-.096.293-.139.406-.128.113-.002.213.076.315.22l3.155,4.019c.159.223.378.565.641,1.025l.041-.054c-.166-.529-.286-.925-.335-1.217l-.976-5.139c-.064-.345.128-.641.602-.917l.293-.165c.46-.262.729-.227.792.117l1.955,10.319c.063.345-.143.655-.603.917l-.223.124c-.153.096-.294.138-.42.141-.113.002-.214-.076-.315-.22l-3.341-4.175c-.102-.118-.277-.407-.525-.854l-.055.081c.164.462.256.806.303,1.044l1.008,5.299c.063.344-.142.641-.602.916l-.293.166c-.446.288-.701.24-.778-.105Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

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