Initial Version
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/public/
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
*.log*
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
/modules/
|
||||||
|
/storage/
|
||||||
|
/var/
|
||||||
143
.stubs/mongodb.stub.php
Normal file
@@ -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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
bin/console
Executable 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
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
42
composer.json
Normal 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
49
config/system.php
Normal 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',
|
||||||
|
//],
|
||||||
|
|
||||||
|
];
|
||||||
361
core/lib/Controllers/AuthenticationController.php
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Controllers;
|
||||||
|
|
||||||
|
use KTXC\Http\Cookie;
|
||||||
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Http\Response\RedirectResponse;
|
||||||
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
|
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||||
|
use KTXC\Security\AuthenticationManager;
|
||||||
|
use KTXF\Controller\ControllerAbstract;
|
||||||
|
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||||
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Controller
|
||||||
|
*/
|
||||||
|
class AuthenticationController extends ControllerAbstract
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AuthenticationManager $authManager
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Authentication Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start authentication session
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/start', name: 'auth.start', methods: ['GET'])]
|
||||||
|
public function start(): JsonResponse
|
||||||
|
{
|
||||||
|
$request = AuthenticationRequest::start();
|
||||||
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify user for identity-first login flow
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/identify', name: 'auth.identify', methods: ['POST'])]
|
||||||
|
public function identify(string $session, string $identity): JsonResponse
|
||||||
|
{
|
||||||
|
if (empty($session) || empty($identity)) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Session and identity are required', 'error_code' => 'invalid_request'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = AuthenticationRequest::identify($session, trim($identity));
|
||||||
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a challenge for methods that require it (SMS, email, TOTP)
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/challenge', name: 'auth.challenge', methods: ['POST'])]
|
||||||
|
public function challenge(string $session, string $method): JsonResponse
|
||||||
|
{
|
||||||
|
if (empty($session) || empty($method)) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = AuthenticationRequest::challenge($session, $method);
|
||||||
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a credential or challenge response
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/verify', name: 'auth.verify', methods: ['POST'])]
|
||||||
|
public function verify(string $session, string $method, string $response): JsonResponse
|
||||||
|
{
|
||||||
|
if (empty($session) || empty($method)) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = AuthenticationRequest::verify($session, $method, $response);
|
||||||
|
$authResponse = $this->authManager->handle($request);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($authResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin redirect-based authentication (OIDC/SAML)
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/redirect', name: 'auth.redirect', methods: ['POST'])]
|
||||||
|
public function redirect(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $this->getRequestData($request);
|
||||||
|
|
||||||
|
$sessionId = $data['session'] ?? '';
|
||||||
|
$method = $data['method'] ?? '';
|
||||||
|
$returnUrl = $data['return_url'] ?? '/';
|
||||||
|
|
||||||
|
if (empty($sessionId) || empty($method)) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = $request->isSecure() ? 'https' : 'http';
|
||||||
|
$host = $request->getHost();
|
||||||
|
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||||
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle callback from identity provider (OIDC/SAML)
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/callback/{provider}', name: 'auth.callback', methods: ['GET', 'POST'])]
|
||||||
|
public function callback(Request $request, string $provider): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
$params = $request->isMethod('POST')
|
||||||
|
? $request->request->all()
|
||||||
|
: $request->query->all();
|
||||||
|
|
||||||
|
$sessionId = $params['state'] ?? null;
|
||||||
|
|
||||||
|
if (!$sessionId) {
|
||||||
|
return $this->redirectWithError('Missing state parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||||
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
if ($response->isSuccess()) {
|
||||||
|
$returnUrl = $response->returnUrl ?? '/';
|
||||||
|
$httpResponse = new RedirectResponse($returnUrl);
|
||||||
|
|
||||||
|
if ($response->hasTokens()) {
|
||||||
|
return $this->setTokenCookies($httpResponse, $response->tokens, $request->isSecure());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $httpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->isPending()) {
|
||||||
|
return new RedirectResponse('/login/mfa?session=' . urlencode($response->sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectWithError($response->errorMessage ?? 'Authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current session status
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/status', name: 'auth.status', methods: ['GET'])]
|
||||||
|
public function status(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$sessionId = $request->query->get('session', '');
|
||||||
|
|
||||||
|
if (empty($sessionId)) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Session ID is required', 'error_code' => 'invalid_request'],
|
||||||
|
JsonResponse::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::status($sessionId);
|
||||||
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
return $this->buildJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel authentication session
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/session', name: 'auth.session.cancel', methods: ['DELETE'])]
|
||||||
|
public function cancel(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$sessionId = $request->query->get('session', '');
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::cancel($sessionId);
|
||||||
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Token Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token
|
||||||
|
*/
|
||||||
|
#[AnonymousRoute('/auth/refresh', name: 'auth.refresh', methods: ['POST'])]
|
||||||
|
public function refresh(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$refreshToken = $request->cookies->get('refreshToken');
|
||||||
|
|
||||||
|
if (!$refreshToken) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => 'Refresh token required', 'error_code' => 'missing_token'],
|
||||||
|
JsonResponse::HTTP_UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
||||||
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
if ($response->isFailed()) {
|
||||||
|
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||||
|
return $this->clearTokenCookies($httpResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
|
||||||
|
|
||||||
|
if ($response->tokens && isset($response->tokens['access'])) {
|
||||||
|
$httpResponse->headers->setCookie(
|
||||||
|
Cookie::create('accessToken')
|
||||||
|
->withValue($response->tokens['access'])
|
||||||
|
->withExpires(time() + 900)
|
||||||
|
->withPath('/')
|
||||||
|
->withSecure($request->isSecure())
|
||||||
|
->withHttpOnly(true)
|
||||||
|
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $httpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current device
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute('/auth/logout', name: 'auth.logout', methods: ['POST'])]
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::logout($token, false);
|
||||||
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
||||||
|
return $this->clearTokenCookies($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout all devices
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute('/auth/logout-all', name: 'auth.logout.all', methods: ['POST'])]
|
||||||
|
public function logoutAll(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
|
$authRequest = AuthenticationRequest::logout($token, true);
|
||||||
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
||||||
|
return $this->clearTokenCookies($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Response Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build JSON response from AuthenticationResponse
|
||||||
|
*/
|
||||||
|
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
|
||||||
|
{
|
||||||
|
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||||
|
|
||||||
|
// Set token cookies if present
|
||||||
|
if ($response->hasTokens()) {
|
||||||
|
return $this->setTokenCookies($httpResponse, $response->tokens, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $httpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set authentication token cookies
|
||||||
|
*/
|
||||||
|
private function setTokenCookies(JsonResponse|RedirectResponse $response, array $tokens, bool $secure = true): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
if (isset($tokens['access'])) {
|
||||||
|
$response->headers->setCookie(
|
||||||
|
Cookie::create('accessToken')
|
||||||
|
->withValue($tokens['access'])
|
||||||
|
->withExpires(time() + 900)
|
||||||
|
->withPath('/')
|
||||||
|
->withSecure($secure)
|
||||||
|
->withHttpOnly(true)
|
||||||
|
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($tokens['refresh'])) {
|
||||||
|
$response->headers->setCookie(
|
||||||
|
Cookie::create('refreshToken')
|
||||||
|
->withValue($tokens['refresh'])
|
||||||
|
->withExpires(time() + 604800)
|
||||||
|
->withPath('/auth/refresh')
|
||||||
|
->withSecure($secure)
|
||||||
|
->withHttpOnly(true)
|
||||||
|
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear authentication token cookies
|
||||||
|
*/
|
||||||
|
private function clearTokenCookies(JsonResponse $response): JsonResponse
|
||||||
|
{
|
||||||
|
$response->headers->clearCookie('accessToken', '/');
|
||||||
|
$response->headers->clearCookie('refreshToken', '/auth/refresh');
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect with error message
|
||||||
|
*/
|
||||||
|
private function redirectWithError(string $error): RedirectResponse
|
||||||
|
{
|
||||||
|
return new RedirectResponse('/login?error=' . urlencode($error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get request data from JSON body or form data
|
||||||
|
*/
|
||||||
|
private function getRequestData(Request $request): array
|
||||||
|
{
|
||||||
|
$contentType = $request->headers->get('Content-Type', '');
|
||||||
|
|
||||||
|
if (str_contains($contentType, 'application/json')) {
|
||||||
|
try {
|
||||||
|
return $request->toArray();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->request->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
145
core/lib/Controllers/DefaultController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
core/lib/Controllers/InitController.php
Normal 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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
58
core/lib/Controllers/ModuleController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
core/lib/Controllers/UserSettingsController.php
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Db;
|
||||||
|
|
||||||
|
use MongoDB\BSON\ObjectId as MongoObjectId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for MongoDB\BSON\ObjectId
|
||||||
|
* Provides abstraction layer for MongoDB ObjectId handling
|
||||||
|
*/
|
||||||
|
class ObjectId
|
||||||
|
{
|
||||||
|
private MongoObjectId $objectId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new ObjectId
|
||||||
|
*
|
||||||
|
* @param string|MongoObjectId|null $id Optional ID string or MongoDB ObjectId
|
||||||
|
*/
|
||||||
|
public function __construct(string|MongoObjectId|null $id = null)
|
||||||
|
{
|
||||||
|
if ($id instanceof MongoObjectId) {
|
||||||
|
$this->objectId = $id;
|
||||||
|
} elseif (is_string($id)) {
|
||||||
|
$this->objectId = new MongoObjectId($id);
|
||||||
|
} else {
|
||||||
|
$this->objectId = new MongoObjectId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the string representation of the ObjectId
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return (string) $this->objectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying MongoDB ObjectId
|
||||||
|
* Used internally when interacting with MongoDB driver
|
||||||
|
*/
|
||||||
|
public function toBSON(): MongoObjectId
|
||||||
|
{
|
||||||
|
return $this->objectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the timestamp from the ObjectId
|
||||||
|
*/
|
||||||
|
public function getTimestamp(): int
|
||||||
|
{
|
||||||
|
return $this->objectId->getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ObjectId from string
|
||||||
|
*/
|
||||||
|
public static function fromString(string $id): self
|
||||||
|
{
|
||||||
|
return new self($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is a valid ObjectId
|
||||||
|
*/
|
||||||
|
public static function isValid(string $id): bool
|
||||||
|
{
|
||||||
|
return MongoObjectId::isValid($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
core/lib/Db/UTCDateTime.php
Normal file
@@ -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
@@ -0,0 +1,407 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a cookie.
|
||||||
|
*
|
||||||
|
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
|
||||||
|
*/
|
||||||
|
class Cookie
|
||||||
|
{
|
||||||
|
public const SAMESITE_NONE = 'none';
|
||||||
|
public const SAMESITE_LAX = 'lax';
|
||||||
|
public const SAMESITE_STRICT = 'strict';
|
||||||
|
|
||||||
|
protected int $expire;
|
||||||
|
protected string $path;
|
||||||
|
|
||||||
|
private ?string $sameSite = null;
|
||||||
|
private bool $secureDefault = false;
|
||||||
|
|
||||||
|
private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f";
|
||||||
|
private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
|
||||||
|
private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates cookie from raw header string.
|
||||||
|
*/
|
||||||
|
public static function fromString(string $cookie, bool $decode = false): static
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'expires' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => null,
|
||||||
|
'secure' => false,
|
||||||
|
'httponly' => false,
|
||||||
|
'raw' => !$decode,
|
||||||
|
'samesite' => null,
|
||||||
|
'partitioned' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
$parts = HeaderUtils::split($cookie, ';=');
|
||||||
|
$part = array_shift($parts);
|
||||||
|
|
||||||
|
$name = $decode ? urldecode($part[0]) : $part[0];
|
||||||
|
$value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
|
||||||
|
|
||||||
|
$data = HeaderUtils::combine($parts) + $data;
|
||||||
|
$data['expires'] = self::expiresTimestamp($data['expires']);
|
||||||
|
|
||||||
|
if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) {
|
||||||
|
$data['expires'] = time() + (int) $data['max-age'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see self::__construct
|
||||||
|
*
|
||||||
|
* @param self::SAMESITE_*|''|null $sameSite
|
||||||
|
*/
|
||||||
|
public static function create(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false): self
|
||||||
|
{
|
||||||
|
return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name The name of the cookie
|
||||||
|
* @param string|null $value The value of the cookie
|
||||||
|
* @param int|string|\DateTimeInterface $expire The time the cookie expires
|
||||||
|
* @param string|null $path The path on the server in which the cookie will be available on
|
||||||
|
* @param string|null $domain The domain that the cookie is available to
|
||||||
|
* @param bool|null $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS
|
||||||
|
* @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
|
||||||
|
* @param bool $raw Whether the cookie value should be sent with no url encoding
|
||||||
|
* @param self::SAMESITE_*|''|null $sameSite Whether the cookie will be available for cross-site requests
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
protected string $name,
|
||||||
|
protected ?string $value = null,
|
||||||
|
int|string|\DateTimeInterface $expire = 0,
|
||||||
|
?string $path = '/',
|
||||||
|
protected ?string $domain = null,
|
||||||
|
protected ?bool $secure = null,
|
||||||
|
protected bool $httpOnly = true,
|
||||||
|
private bool $raw = false,
|
||||||
|
?string $sameSite = self::SAMESITE_LAX,
|
||||||
|
private bool $partitioned = false,
|
||||||
|
) {
|
||||||
|
// from PHP source code
|
||||||
|
if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$name) {
|
||||||
|
throw new \InvalidArgumentException('The cookie name cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expire = self::expiresTimestamp($expire);
|
||||||
|
$this->path = $path ?: '/';
|
||||||
|
$this->sameSite = $this->withSameSite($sameSite)->sameSite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy with a new value.
|
||||||
|
*/
|
||||||
|
public function withValue(?string $value): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->value = $value;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy with a new domain that the cookie is available to.
|
||||||
|
*/
|
||||||
|
public function withDomain(?string $domain): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->domain = $domain;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy with a new time the cookie expires.
|
||||||
|
*/
|
||||||
|
public function withExpires(int|string|\DateTimeInterface $expire = 0): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->expire = self::expiresTimestamp($expire);
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts expires formats to a unix timestamp.
|
||||||
|
*/
|
||||||
|
private static function expiresTimestamp(int|string|\DateTimeInterface $expire = 0): int
|
||||||
|
{
|
||||||
|
// convert expiration time to a Unix timestamp
|
||||||
|
if ($expire instanceof \DateTimeInterface) {
|
||||||
|
$expire = $expire->format('U');
|
||||||
|
} elseif (!is_numeric($expire)) {
|
||||||
|
$expire = strtotime($expire);
|
||||||
|
|
||||||
|
if (false === $expire) {
|
||||||
|
throw new \InvalidArgumentException('The cookie expiration time is not valid.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0 < $expire ? (int) $expire : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy with a new path on the server in which the cookie will be available on.
|
||||||
|
*/
|
||||||
|
public function withPath(string $path): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->path = '' === $path ? '/' : $path;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client.
|
||||||
|
*/
|
||||||
|
public function withSecure(bool $secure = true): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->secure = $secure;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy that be accessible only through the HTTP protocol.
|
||||||
|
*/
|
||||||
|
public function withHttpOnly(bool $httpOnly = true): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->httpOnly = $httpOnly;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy that uses no url encoding.
|
||||||
|
*/
|
||||||
|
public function withRaw(bool $raw = true): static
|
||||||
|
{
|
||||||
|
if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $this->name));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->raw = $raw;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy with SameSite attribute.
|
||||||
|
*
|
||||||
|
* @param self::SAMESITE_*|''|null $sameSite
|
||||||
|
*/
|
||||||
|
public function withSameSite(?string $sameSite): static
|
||||||
|
{
|
||||||
|
if ('' === $sameSite) {
|
||||||
|
$sameSite = null;
|
||||||
|
} elseif (null !== $sameSite) {
|
||||||
|
$sameSite = strtolower($sameSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) {
|
||||||
|
throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->sameSite = $sameSite;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cookie copy that is tied to the top-level site in cross-site context.
|
||||||
|
*/
|
||||||
|
public function withPartitioned(bool $partitioned = true): static
|
||||||
|
{
|
||||||
|
$cookie = clone $this;
|
||||||
|
$cookie->partitioned = $partitioned;
|
||||||
|
|
||||||
|
return $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cookie as a string.
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
if ($this->isRaw()) {
|
||||||
|
$str = $this->getName();
|
||||||
|
} else {
|
||||||
|
$str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
$str .= '=';
|
||||||
|
|
||||||
|
if ('' === (string) $this->getValue()) {
|
||||||
|
$str .= 'deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0';
|
||||||
|
} else {
|
||||||
|
$str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue());
|
||||||
|
|
||||||
|
if (0 !== $this->getExpiresTime()) {
|
||||||
|
$str .= '; expires='.gmdate('D, d M Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->getPath()) {
|
||||||
|
$str .= '; path='.$this->getPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->getDomain()) {
|
||||||
|
$str .= '; domain='.$this->getDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSecure()) {
|
||||||
|
$str .= '; secure';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isHttpOnly()) {
|
||||||
|
$str .= '; httponly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->getSameSite()) {
|
||||||
|
$str .= '; samesite='.$this->getSameSite();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPartitioned()) {
|
||||||
|
$str .= '; partitioned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the name of the cookie.
|
||||||
|
*/
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of the cookie.
|
||||||
|
*/
|
||||||
|
public function getValue(): ?string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the domain that the cookie is available to.
|
||||||
|
*/
|
||||||
|
public function getDomain(): ?string
|
||||||
|
{
|
||||||
|
return $this->domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the time the cookie expires.
|
||||||
|
*/
|
||||||
|
public function getExpiresTime(): int
|
||||||
|
{
|
||||||
|
return $this->expire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the max-age attribute.
|
||||||
|
*/
|
||||||
|
public function getMaxAge(): int
|
||||||
|
{
|
||||||
|
$maxAge = $this->expire - time();
|
||||||
|
|
||||||
|
return max(0, $maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the path on the server in which the cookie will be available on.
|
||||||
|
*/
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return $this->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
|
||||||
|
*/
|
||||||
|
public function isSecure(): bool
|
||||||
|
{
|
||||||
|
return $this->secure ?? $this->secureDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the cookie will be made accessible only through the HTTP protocol.
|
||||||
|
*/
|
||||||
|
public function isHttpOnly(): bool
|
||||||
|
{
|
||||||
|
return $this->httpOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this cookie is about to be cleared.
|
||||||
|
*/
|
||||||
|
public function isCleared(): bool
|
||||||
|
{
|
||||||
|
return 0 !== $this->expire && $this->expire < time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the cookie value should be sent with no url encoding.
|
||||||
|
*/
|
||||||
|
public function isRaw(): bool
|
||||||
|
{
|
||||||
|
return $this->raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the cookie should be tied to the top-level site in cross-site context.
|
||||||
|
*/
|
||||||
|
public function isPartitioned(): bool
|
||||||
|
{
|
||||||
|
return $this->partitioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self::SAMESITE_*|null
|
||||||
|
*/
|
||||||
|
public function getSameSite(): ?string
|
||||||
|
{
|
||||||
|
return $this->sameSite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $default The default value of the "secure" flag when it is set to null
|
||||||
|
*/
|
||||||
|
public function setSecureDefault(bool $default): void
|
||||||
|
{
|
||||||
|
$this->secureDefault = $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
core/lib/Http/Exception/BadRequestException.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
use KTXF\Exception\BaseException;
|
||||||
|
|
||||||
|
class BadRequestException extends BaseException
|
||||||
|
{
|
||||||
|
}
|
||||||
12
core/lib/Http/Exception/ConflictingHeadersException.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when request headers conflict with each other.
|
||||||
|
*/
|
||||||
|
class ConflictingHeadersException extends \UnexpectedValueException
|
||||||
|
{
|
||||||
|
}
|
||||||
12
core/lib/Http/Exception/JsonException.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when JSON decoding/encoding fails in HTTP context.
|
||||||
|
*/
|
||||||
|
class JsonException extends \UnexpectedValueException
|
||||||
|
{
|
||||||
|
}
|
||||||
12
core/lib/Http/Exception/SessionNotFoundException.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a session is expected but not available.
|
||||||
|
*/
|
||||||
|
class SessionNotFoundException extends \LogicException
|
||||||
|
{
|
||||||
|
}
|
||||||
12
core/lib/Http/Exception/SuspiciousOperationException.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a suspicious operation is detected (e.g., invalid host).
|
||||||
|
*/
|
||||||
|
class SuspiciousOperationException extends \UnexpectedValueException
|
||||||
|
{
|
||||||
|
}
|
||||||
9
core/lib/Http/Exception/UnexpectedValueException.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Exception;
|
||||||
|
|
||||||
|
use KTXF\Exception\RuntimeException;
|
||||||
|
|
||||||
|
class UnexpectedValueException extends RuntimeException {}
|
||||||
288
core/lib/Http/File/UploadedFile.php
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a file uploaded through an HTTP request.
|
||||||
|
*/
|
||||||
|
class UploadedFile extends \SplFileInfo
|
||||||
|
{
|
||||||
|
private string $originalName;
|
||||||
|
private ?string $mimeType;
|
||||||
|
private int $error;
|
||||||
|
private bool $test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the information of the uploaded file as provided by the PHP global $_FILES.
|
||||||
|
*
|
||||||
|
* @param string $path The full temporary path to the file
|
||||||
|
* @param string $originalName The original file name of the uploaded file
|
||||||
|
* @param string|null $mimeType The type of the file as provided by PHP; null defaults to application/octet-stream
|
||||||
|
* @param int|null $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants); null defaults to UPLOAD_ERR_OK
|
||||||
|
* @param bool $test Whether the test mode is active (used for testing)
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the file is not readable
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $path,
|
||||||
|
string $originalName,
|
||||||
|
?string $mimeType = null,
|
||||||
|
?int $error = null,
|
||||||
|
bool $test = false
|
||||||
|
) {
|
||||||
|
$this->originalName = $this->getName($originalName);
|
||||||
|
$this->mimeType = $mimeType ?? 'application/octet-stream';
|
||||||
|
$this->error = $error ?? \UPLOAD_ERR_OK;
|
||||||
|
$this->test = $test;
|
||||||
|
|
||||||
|
parent::__construct($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the original file name.
|
||||||
|
*
|
||||||
|
* It is extracted from the request from which the file has been uploaded.
|
||||||
|
* This should not be considered as a safe value to use for a file name on your servers.
|
||||||
|
*
|
||||||
|
* @return string The original name
|
||||||
|
*/
|
||||||
|
public function getClientOriginalName(): string
|
||||||
|
{
|
||||||
|
return $this->originalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the original file extension.
|
||||||
|
*
|
||||||
|
* It is extracted from the original file name that was uploaded.
|
||||||
|
* This should not be considered as a safe value to use for a file name on your servers.
|
||||||
|
*
|
||||||
|
* @return string The extension
|
||||||
|
*/
|
||||||
|
public function getClientOriginalExtension(): string
|
||||||
|
{
|
||||||
|
return pathinfo($this->originalName, \PATHINFO_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the file mime type.
|
||||||
|
*
|
||||||
|
* The client mime type is extracted from the request from which the file was uploaded,
|
||||||
|
* so it should not be considered as a safe value.
|
||||||
|
*
|
||||||
|
* @return string The mime type
|
||||||
|
*/
|
||||||
|
public function getClientMimeType(): string
|
||||||
|
{
|
||||||
|
return $this->mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the extension based on the client mime type.
|
||||||
|
*
|
||||||
|
* If the mime type is unknown, returns null.
|
||||||
|
*
|
||||||
|
* This method uses a built-in list of mime type / extension pairs.
|
||||||
|
*
|
||||||
|
* @return string|null The guessed extension or null if it cannot be guessed
|
||||||
|
*/
|
||||||
|
public function guessClientExtension(): ?string
|
||||||
|
{
|
||||||
|
return self::mimeToExtension($this->mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the upload error.
|
||||||
|
*
|
||||||
|
* If the upload was successful, the constant UPLOAD_ERR_OK is returned.
|
||||||
|
* Otherwise one of the other UPLOAD_ERR_XXX constants is returned.
|
||||||
|
*
|
||||||
|
* @return int The upload error
|
||||||
|
*/
|
||||||
|
public function getError(): int
|
||||||
|
{
|
||||||
|
return $this->error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the file has been uploaded with HTTP and no error occurred.
|
||||||
|
*
|
||||||
|
* @return bool True if the file is valid, false otherwise
|
||||||
|
*/
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
$isOk = \UPLOAD_ERR_OK === $this->error;
|
||||||
|
|
||||||
|
return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the file to a new location.
|
||||||
|
*
|
||||||
|
* @param string $directory The destination folder
|
||||||
|
* @param string|null $name The new file name
|
||||||
|
*
|
||||||
|
* @return \SplFileInfo A SplFileInfo object for the new file
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if the file cannot be moved
|
||||||
|
*/
|
||||||
|
public function move(string $directory, ?string $name = null): \SplFileInfo
|
||||||
|
{
|
||||||
|
if ($this->isValid()) {
|
||||||
|
if ($this->test) {
|
||||||
|
return $this->doMove($directory, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $this->getTargetFile($directory, $name);
|
||||||
|
|
||||||
|
if (!@move_uploaded_file($this->getPathname(), $target)) {
|
||||||
|
$error = error_get_last();
|
||||||
|
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($target, 0666 & ~umask());
|
||||||
|
|
||||||
|
return new \SplFileInfo($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException($this->getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum size of an uploaded file as configured in php.ini.
|
||||||
|
*
|
||||||
|
* @return int|float The maximum size of an uploaded file in bytes (returns float on 32-bit for large values)
|
||||||
|
*/
|
||||||
|
public static function getMaxFilesize(): int|float
|
||||||
|
{
|
||||||
|
$sizePostMax = self::parseFilesize(\ini_get('post_max_size'));
|
||||||
|
$sizeUploadMax = self::parseFilesize(\ini_get('upload_max_filesize'));
|
||||||
|
|
||||||
|
return min($sizePostMax ?: \PHP_INT_MAX, $sizeUploadMax ?: \PHP_INT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an informative upload error message.
|
||||||
|
*
|
||||||
|
* @return string The error message regarding the specified error code
|
||||||
|
*/
|
||||||
|
public function getErrorMessage(): string
|
||||||
|
{
|
||||||
|
return match ($this->error) {
|
||||||
|
\UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive.',
|
||||||
|
\UPLOAD_ERR_FORM_SIZE => 'The file "%s" exceeds the upload limit defined in your form.',
|
||||||
|
\UPLOAD_ERR_PARTIAL => 'The file "%s" was only partially uploaded.',
|
||||||
|
\UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
|
||||||
|
\UPLOAD_ERR_CANT_WRITE => 'The file "%s" could not be written on disk.',
|
||||||
|
\UPLOAD_ERR_NO_TMP_DIR => 'File could not be uploaded: missing temporary directory.',
|
||||||
|
\UPLOAD_ERR_EXTENSION => 'File upload was stopped by a PHP extension.',
|
||||||
|
default => 'The file "%s" was not uploaded due to an unknown error.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns locale independent base name of the given path.
|
||||||
|
*
|
||||||
|
* @param string $name The new file name
|
||||||
|
*
|
||||||
|
* @return string The base name
|
||||||
|
*/
|
||||||
|
protected function getName(string $name): string
|
||||||
|
{
|
||||||
|
$originalName = str_replace('\\', '/', $name);
|
||||||
|
$pos = strrpos($originalName, '/');
|
||||||
|
$originalName = false === $pos ? $originalName : substr($originalName, $pos + 1);
|
||||||
|
|
||||||
|
return $originalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTargetFile(string $directory, ?string $name = null): string
|
||||||
|
{
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) {
|
||||||
|
throw new \RuntimeException(sprintf('Unable to create the "%s" directory.', $directory));
|
||||||
|
}
|
||||||
|
} elseif (!is_writable($directory)) {
|
||||||
|
throw new \RuntimeException(sprintf('Unable to write in the "%s" directory.', $directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = rtrim($directory, '/\\') . \DIRECTORY_SEPARATOR . (null === $name ? $this->getBasename() : $this->getName($name));
|
||||||
|
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the file to a new location (used in test mode).
|
||||||
|
*/
|
||||||
|
protected function doMove(string $directory, ?string $name = null): \SplFileInfo
|
||||||
|
{
|
||||||
|
$target = $this->getTargetFile($directory, $name);
|
||||||
|
|
||||||
|
if (!@rename($this->getPathname(), $target)) {
|
||||||
|
$error = error_get_last();
|
||||||
|
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($target, 0666 & ~umask());
|
||||||
|
|
||||||
|
return new \SplFileInfo($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseFilesize(string $size): int|float
|
||||||
|
{
|
||||||
|
if ('' === $size) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = strtolower($size);
|
||||||
|
|
||||||
|
$max = ltrim($size, '+');
|
||||||
|
if (str_starts_with($max, '0x')) {
|
||||||
|
$max = \intval($max, 16);
|
||||||
|
} elseif (str_starts_with($max, '0')) {
|
||||||
|
$max = \intval($max, 8);
|
||||||
|
} else {
|
||||||
|
$max = (int) $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (substr($size, -1)) {
|
||||||
|
case 't': $max *= 1024;
|
||||||
|
// no break
|
||||||
|
case 'g': $max *= 1024;
|
||||||
|
// no break
|
||||||
|
case 'm': $max *= 1024;
|
||||||
|
// no break
|
||||||
|
case 'k': $max *= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mimeToExtension(string $mimeType): ?string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'application/zip' => 'zip',
|
||||||
|
'application/json' => 'json',
|
||||||
|
'application/xml' => 'xml',
|
||||||
|
'application/octet-stream' => 'bin',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'image/svg+xml' => 'svg',
|
||||||
|
'text/plain' => 'txt',
|
||||||
|
'text/html' => 'html',
|
||||||
|
'text/css' => 'css',
|
||||||
|
'text/javascript' => 'js',
|
||||||
|
'audio/mpeg' => 'mp3',
|
||||||
|
'audio/wav' => 'wav',
|
||||||
|
'video/mp4' => 'mp4',
|
||||||
|
'video/webm' => 'webm',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $map[$mimeType] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
core/lib/Http/HeaderParameters.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeaderBag is a container for HTTP headers.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* @implements \IteratorAggregate<string, list<string|null>>
|
||||||
|
*/
|
||||||
|
class HeaderParameters implements \IteratorAggregate, \Countable, \Stringable
|
||||||
|
{
|
||||||
|
protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
protected const LOWER = '-abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, list<string|null>>
|
||||||
|
*/
|
||||||
|
protected array $headers = [];
|
||||||
|
protected array $cacheControl = [];
|
||||||
|
|
||||||
|
public function __construct(array $headers = [])
|
||||||
|
{
|
||||||
|
foreach ($headers as $key => $values) {
|
||||||
|
$this->set($key, $values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the headers as a string.
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
if (!$headers = $this->all()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($headers);
|
||||||
|
$max = max(array_map('strlen', array_keys($headers))) + 1;
|
||||||
|
$content = '';
|
||||||
|
foreach ($headers as $name => $values) {
|
||||||
|
$name = ucwords($name, '-');
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the headers.
|
||||||
|
*
|
||||||
|
* @param string|null $key The name of the headers to return or null to get them all
|
||||||
|
*
|
||||||
|
* @return ($key is null ? array<string, list<string|null>> : list<string|null>)
|
||||||
|
*/
|
||||||
|
public function all(?string $key = null): array
|
||||||
|
{
|
||||||
|
if (null !== $key) {
|
||||||
|
return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter keys.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the current HTTP headers by a new set.
|
||||||
|
*/
|
||||||
|
public function replace(array $headers = []): void
|
||||||
|
{
|
||||||
|
$this->headers = [];
|
||||||
|
$this->add($headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds new headers the current HTTP headers set.
|
||||||
|
*/
|
||||||
|
public function add(array $headers): void
|
||||||
|
{
|
||||||
|
foreach ($headers as $key => $values) {
|
||||||
|
$this->set($key, $values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first header by name or the default one.
|
||||||
|
*/
|
||||||
|
public function get(string $key, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
$headers = $this->all($key);
|
||||||
|
|
||||||
|
if (!$headers) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $headers[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a header by name.
|
||||||
|
*
|
||||||
|
* @param string|string[]|null $values The value or an array of values
|
||||||
|
* @param bool $replace Whether to replace the actual value or not (true by default)
|
||||||
|
*/
|
||||||
|
public function set(string $key, string|array|null $values, bool $replace = true): void
|
||||||
|
{
|
||||||
|
$key = strtr($key, self::UPPER, self::LOWER);
|
||||||
|
|
||||||
|
if (\is_array($values)) {
|
||||||
|
$values = array_values($values);
|
||||||
|
|
||||||
|
if (true === $replace || !isset($this->headers[$key])) {
|
||||||
|
$this->headers[$key] = $values;
|
||||||
|
} else {
|
||||||
|
$this->headers[$key] = array_merge($this->headers[$key], $values);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (true === $replace || !isset($this->headers[$key])) {
|
||||||
|
$this->headers[$key] = [$values];
|
||||||
|
} else {
|
||||||
|
$this->headers[$key][] = $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('cache-control' === $key) {
|
||||||
|
$this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the HTTP header is defined.
|
||||||
|
*/
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return \array_key_exists(strtr($key, self::UPPER, self::LOWER), $this->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given HTTP header contains the given value.
|
||||||
|
*/
|
||||||
|
public function contains(string $key, string $value): bool
|
||||||
|
{
|
||||||
|
return \in_array($value, $this->all($key), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a header.
|
||||||
|
*/
|
||||||
|
public function remove(string $key): void
|
||||||
|
{
|
||||||
|
$key = strtr($key, self::UPPER, self::LOWER);
|
||||||
|
|
||||||
|
unset($this->headers[$key]);
|
||||||
|
|
||||||
|
if ('cache-control' === $key) {
|
||||||
|
$this->cacheControl = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the HTTP header value converted to a date.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException When the HTTP header is not parseable
|
||||||
|
*/
|
||||||
|
public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (null === $value = $this->get($key)) {
|
||||||
|
return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) {
|
||||||
|
throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a custom Cache-Control directive.
|
||||||
|
*/
|
||||||
|
public function addCacheControlDirective(string $key, bool|string $value = true): void
|
||||||
|
{
|
||||||
|
$this->cacheControl[$key] = $value;
|
||||||
|
|
||||||
|
$this->set('Cache-Control', $this->getCacheControlHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the Cache-Control directive is defined.
|
||||||
|
*/
|
||||||
|
public function hasCacheControlDirective(string $key): bool
|
||||||
|
{
|
||||||
|
return \array_key_exists($key, $this->cacheControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Cache-Control directive value by name.
|
||||||
|
*/
|
||||||
|
public function getCacheControlDirective(string $key): bool|string|null
|
||||||
|
{
|
||||||
|
return $this->cacheControl[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a Cache-Control directive.
|
||||||
|
*/
|
||||||
|
public function removeCacheControlDirective(string $key): void
|
||||||
|
{
|
||||||
|
unset($this->cacheControl[$key]);
|
||||||
|
|
||||||
|
$this->set('Cache-Control', $this->getCacheControlHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an iterator for headers.
|
||||||
|
*
|
||||||
|
* @return \ArrayIterator<string, list<string|null>>
|
||||||
|
*/
|
||||||
|
public function getIterator(): \ArrayIterator
|
||||||
|
{
|
||||||
|
return new \ArrayIterator($this->headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of headers.
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return \count($this->headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCacheControlHeader(): string
|
||||||
|
{
|
||||||
|
ksort($this->cacheControl);
|
||||||
|
|
||||||
|
return HeaderUtils::toString($this->cacheControl, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a Cache-Control HTTP header.
|
||||||
|
*/
|
||||||
|
protected function parseCacheControl(string $header): array
|
||||||
|
{
|
||||||
|
$parts = HeaderUtils::split($header, ',=');
|
||||||
|
|
||||||
|
return HeaderUtils::combine($parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
298
core/lib/Http/HeaderUtils.php
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2107
core/lib/Http/Request/Request.php
Normal file
129
core/lib/Http/Request/RequestFileCollection.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileBag is a container for uploaded files.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
|
||||||
|
*/
|
||||||
|
class RequestFileCollection extends RequestParameters
|
||||||
|
{
|
||||||
|
private const FILE_KEYS = ['error', 'full_path', 'name', 'size', 'tmp_name', 'type'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array|UploadedFile[] $parameters An array of HTTP files
|
||||||
|
*/
|
||||||
|
public function __construct(array $parameters = [])
|
||||||
|
{
|
||||||
|
$this->replace($parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replace(array $files = []): void
|
||||||
|
{
|
||||||
|
$this->parameters = [];
|
||||||
|
$this->add($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if (!\is_array($value) && !$value instanceof UploadedFile) {
|
||||||
|
throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::set($key, $this->convertFileInformation($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(array $files = []): void
|
||||||
|
{
|
||||||
|
foreach ($files as $key => $file) {
|
||||||
|
$this->set($key, $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts uploaded files to UploadedFile instances.
|
||||||
|
*
|
||||||
|
* @return UploadedFile[]|UploadedFile|null
|
||||||
|
*/
|
||||||
|
protected function convertFileInformation(array|UploadedFile $file): array|UploadedFile|null
|
||||||
|
{
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->fixPhpFilesArray($file);
|
||||||
|
$keys = array_keys($file + ['full_path' => null]);
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
if (self::FILE_KEYS === $keys) {
|
||||||
|
if (\UPLOAD_ERR_NO_FILE === $file['error']) {
|
||||||
|
$file = null;
|
||||||
|
} else {
|
||||||
|
$file = new UploadedFile($file['tmp_name'], $file['full_path'] ?? $file['name'], $file['type'], $file['error'], false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file);
|
||||||
|
if (array_is_list($file)) {
|
||||||
|
$file = array_filter($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixes a malformed PHP $_FILES array.
|
||||||
|
*
|
||||||
|
* PHP has a bug that the format of the $_FILES array differs, depending on
|
||||||
|
* whether the uploaded file fields had normal field names or array-like
|
||||||
|
* field names ("normal" vs. "parent[child]").
|
||||||
|
*
|
||||||
|
* This method fixes the array to look like the "normal" $_FILES array.
|
||||||
|
*
|
||||||
|
* It's safe to pass an already converted array, in which case this method
|
||||||
|
* just returns the original array unmodified.
|
||||||
|
*/
|
||||||
|
protected function fixPhpFilesArray(array $data): array
|
||||||
|
{
|
||||||
|
$keys = array_keys($data + ['full_path' => null]);
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = $data;
|
||||||
|
foreach (self::FILE_KEYS as $k) {
|
||||||
|
unset($files[$k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['name'] as $key => $name) {
|
||||||
|
$files[$key] = $this->fixPhpFilesArray([
|
||||||
|
'error' => $data['error'][$key],
|
||||||
|
'name' => $name,
|
||||||
|
'type' => $data['type'][$key],
|
||||||
|
'tmp_name' => $data['tmp_name'][$key],
|
||||||
|
'size' => $data['size'][$key],
|
||||||
|
] + (isset($data['full_path'][$key]) ? [
|
||||||
|
'full_path' => $data['full_path'][$key],
|
||||||
|
] : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
}
|
||||||
154
core/lib/Http/Request/RequestHeaderAccept.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\HeaderUtils;
|
||||||
|
|
||||||
|
// Help opcache.preload discover always-needed symbols
|
||||||
|
class_exists(RequestHeaderAcceptItem::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an Accept-* header.
|
||||||
|
*
|
||||||
|
* An accept header is compound with a list of items,
|
||||||
|
* sorted by descending quality.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||||
|
*/
|
||||||
|
class RequestHeaderAccept
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RequestHeaderAcceptItem[]
|
||||||
|
*/
|
||||||
|
private array $items = [];
|
||||||
|
|
||||||
|
private bool $sorted = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param RequestHeaderAcceptItem[] $items
|
||||||
|
*/
|
||||||
|
public function __construct(array $items)
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$this->add($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an AcceptHeader instance from a string.
|
||||||
|
*/
|
||||||
|
public static function fromString(?string $headerValue): self
|
||||||
|
{
|
||||||
|
$parts = HeaderUtils::split($headerValue ?? '', ',;=');
|
||||||
|
|
||||||
|
return new self(array_map(function ($subParts) {
|
||||||
|
static $index = 0;
|
||||||
|
$part = array_shift($subParts);
|
||||||
|
$attributes = HeaderUtils::combine($subParts);
|
||||||
|
|
||||||
|
$item = new RequestHeaderAcceptItem($part[0], $attributes);
|
||||||
|
$item->setIndex($index++);
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}, $parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns header value's string representation.
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return implode(',', $this->items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if header has given value.
|
||||||
|
*/
|
||||||
|
public function has(string $value): bool
|
||||||
|
{
|
||||||
|
return isset($this->items[$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns given value's item, if exists.
|
||||||
|
*/
|
||||||
|
public function get(string $value): ?RequestHeaderAcceptItem
|
||||||
|
{
|
||||||
|
return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an item.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function add(RequestHeaderAcceptItem $item): static
|
||||||
|
{
|
||||||
|
$this->items[$item->getValue()] = $item;
|
||||||
|
$this->sorted = false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all items.
|
||||||
|
*
|
||||||
|
* @return RequestHeaderAcceptItem[]
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$this->sort();
|
||||||
|
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters items on their value using given regex.
|
||||||
|
*/
|
||||||
|
public function filter(string $pattern): self
|
||||||
|
{
|
||||||
|
return new self(array_filter($this->items, fn (RequestHeaderAcceptItem $item) => preg_match($pattern, $item->getValue())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns first item.
|
||||||
|
*/
|
||||||
|
public function first(): ?RequestHeaderAcceptItem
|
||||||
|
{
|
||||||
|
$this->sort();
|
||||||
|
|
||||||
|
return $this->items ? reset($this->items) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts items by descending quality.
|
||||||
|
*/
|
||||||
|
private function sort(): void
|
||||||
|
{
|
||||||
|
if (!$this->sorted) {
|
||||||
|
uasort($this->items, function (RequestHeaderAcceptItem $a, RequestHeaderAcceptItem $b) {
|
||||||
|
$qA = $a->getQuality();
|
||||||
|
$qB = $b->getQuality();
|
||||||
|
|
||||||
|
if ($qA === $qB) {
|
||||||
|
return $a->getIndex() > $b->getIndex() ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qA > $qB ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->sorted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
core/lib/Http/Request/RequestHeaderAcceptItem.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\HeaderUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an Accept-* header item.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||||
|
*/
|
||||||
|
class RequestHeaderAcceptItem
|
||||||
|
{
|
||||||
|
private float $quality = 1.0;
|
||||||
|
private int $index = 0;
|
||||||
|
private array $attributes = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $value,
|
||||||
|
array $attributes = [],
|
||||||
|
) {
|
||||||
|
foreach ($attributes as $name => $value) {
|
||||||
|
$this->setAttribute($name, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an AcceptHeaderInstance instance from a string.
|
||||||
|
*/
|
||||||
|
public static function fromString(?string $itemValue): self
|
||||||
|
{
|
||||||
|
$parts = HeaderUtils::split($itemValue ?? '', ';=');
|
||||||
|
|
||||||
|
$part = array_shift($parts);
|
||||||
|
$attributes = HeaderUtils::combine($parts);
|
||||||
|
|
||||||
|
return new self($part[0], $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns header value's string representation.
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
|
||||||
|
if (\count($this->attributes) > 0) {
|
||||||
|
$string .= '; '.HeaderUtils::toString($this->attributes, ';');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the item value.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setValue(string $value): static
|
||||||
|
{
|
||||||
|
$this->value = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the item value.
|
||||||
|
*/
|
||||||
|
public function getValue(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the item quality.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setQuality(float $quality): static
|
||||||
|
{
|
||||||
|
$this->quality = $quality;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the item quality.
|
||||||
|
*/
|
||||||
|
public function getQuality(): float
|
||||||
|
{
|
||||||
|
return $this->quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the item index.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setIndex(int $index): static
|
||||||
|
{
|
||||||
|
$this->index = $index;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the item index.
|
||||||
|
*/
|
||||||
|
public function getIndex(): int
|
||||||
|
{
|
||||||
|
return $this->index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if an attribute exists.
|
||||||
|
*/
|
||||||
|
public function hasAttribute(string $name): bool
|
||||||
|
{
|
||||||
|
return isset($this->attributes[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an attribute by its name.
|
||||||
|
*/
|
||||||
|
public function getAttribute(string $name, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->attributes[$name] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all attributes.
|
||||||
|
*/
|
||||||
|
public function getAttributes(): array
|
||||||
|
{
|
||||||
|
return $this->attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an attribute.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setAttribute(string $name, string $value): static
|
||||||
|
{
|
||||||
|
if ('q' === $name) {
|
||||||
|
$this->quality = (float) $value;
|
||||||
|
} else {
|
||||||
|
$this->attributes[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
core/lib/Http/Request/RequestHeaderParameters.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\HeaderParameters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeaderBag is a container for HTTP headers.
|
||||||
|
*/
|
||||||
|
class RequestHeaderParameters extends HeaderParameters {}
|
||||||
155
core/lib/Http/Request/RequestInputParameters.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\Exception\BadRequestException;
|
||||||
|
use KTXC\Http\Exception\UnexpectedValueException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE.
|
||||||
|
*
|
||||||
|
* @author Saif Eddin Gmati <azjezz@protonmail.com>
|
||||||
|
*/
|
||||||
|
final class RequestInputParameters extends RequestParameters
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns an input value by name (scalar, Stringable, or array).
|
||||||
|
*
|
||||||
|
* Arrays are now allowed. (Previously only scalar values were permitted.)
|
||||||
|
* No deep validation of array contents is performed here; callers should
|
||||||
|
* sanitize nested values as needed.
|
||||||
|
*
|
||||||
|
* @param string|int|float|bool|array|null $default The default value if the key does not exist
|
||||||
|
*
|
||||||
|
* @return string|int|float|bool|array|null
|
||||||
|
*
|
||||||
|
* @throws BadRequestException if the stored input value is of an unsupported type
|
||||||
|
* @throws \InvalidArgumentException if the provided default is of an unsupported type
|
||||||
|
*/
|
||||||
|
public function get(string $key, mixed $default = null): string|int|float|bool|array|null
|
||||||
|
{
|
||||||
|
if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable && !\is_array($default)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Expected a scalar or array value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = parent::get($key, $this);
|
||||||
|
|
||||||
|
if (null !== $value && $this !== $value && !\is_scalar($value) && !$value instanceof \Stringable && !\is_array($value)) {
|
||||||
|
throw new BadRequestException(\sprintf('Input value "%s" contains an invalid (non-scalar, non-array, non-Stringable) value.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this === $value ? $default : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the current input values by a new set.
|
||||||
|
*/
|
||||||
|
public function replace(array $inputs = []): void
|
||||||
|
{
|
||||||
|
$this->parameters = [];
|
||||||
|
$this->add($inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds input values.
|
||||||
|
*/
|
||||||
|
public function add(array $inputs = []): void
|
||||||
|
{
|
||||||
|
foreach ($inputs as $input => $value) {
|
||||||
|
$this->set($input, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an input by name.
|
||||||
|
*
|
||||||
|
* @param string|int|float|bool|array|null $value
|
||||||
|
*/
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->parameters[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter value converted to an enum.
|
||||||
|
*
|
||||||
|
* @template T of \BackedEnum
|
||||||
|
*
|
||||||
|
* @param class-string<T> $class
|
||||||
|
* @param ?T $default
|
||||||
|
*
|
||||||
|
* @return ?T
|
||||||
|
*
|
||||||
|
* @psalm-return ($default is null ? T|null : T)
|
||||||
|
*
|
||||||
|
* @throws BadRequestException if the input cannot be converted to an enum
|
||||||
|
*/
|
||||||
|
public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return parent::getEnum($key, $class, $default);
|
||||||
|
} catch (UnexpectedValueException $e) {
|
||||||
|
throw new BadRequestException($e->getMessage(), $e->getCode(), $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter value converted to string.
|
||||||
|
*
|
||||||
|
* @throws BadRequestException if the input contains a non-scalar value
|
||||||
|
*/
|
||||||
|
public function getString(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
// Shortcuts the parent method because the validation on scalar is already done in get().
|
||||||
|
return (string) $this->get($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BadRequestException if the input value is an array and \FILTER_REQUIRE_ARRAY or \FILTER_FORCE_ARRAY is not set
|
||||||
|
* @throws BadRequestException if the input value is invalid and \FILTER_NULL_ON_FAILURE is not set
|
||||||
|
*/
|
||||||
|
public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed
|
||||||
|
{
|
||||||
|
$value = $this->has($key) ? $this->all()[$key] : $default;
|
||||||
|
|
||||||
|
// Always turn $options into an array - this allows filter_var option shortcuts.
|
||||||
|
if (!\is_array($options) && $options) {
|
||||||
|
$options = ['flags' => $options];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($value) && !(($options['flags'] ?? 0) & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) {
|
||||||
|
throw new BadRequestException(\sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$options['flags'] ??= 0;
|
||||||
|
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
|
||||||
|
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
|
||||||
|
|
||||||
|
$value = filter_var($value, $filter, $options);
|
||||||
|
|
||||||
|
if (null !== $value || $nullOnFailure) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
|
||||||
|
}
|
||||||
|
}
|
||||||
260
core/lib/Http/Request/RequestParameters.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
use KTXC\Http\Exception\BadRequestException;
|
||||||
|
use KTXC\Http\Exception\UnexpectedValueException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParameterBag is a container for key/value pairs.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* @implements \IteratorAggregate<string, mixed>
|
||||||
|
*/
|
||||||
|
class RequestParameters implements \IteratorAggregate, \Countable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected array $parameters = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameters.
|
||||||
|
*
|
||||||
|
* @param string|null $key The name of the parameter to return or null to get them all
|
||||||
|
*
|
||||||
|
* @throws BadRequestException if the value is not an array
|
||||||
|
*/
|
||||||
|
public function all(?string $key = null): array
|
||||||
|
{
|
||||||
|
if (null === $key) {
|
||||||
|
return $this->parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_array($value = $this->parameters[$key] ?? [])) {
|
||||||
|
throw new BadRequestException(\sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter keys.
|
||||||
|
*/
|
||||||
|
public function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the current parameters by a new set.
|
||||||
|
*/
|
||||||
|
public function replace(array $parameters = []): void
|
||||||
|
{
|
||||||
|
$this->parameters = $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds parameters.
|
||||||
|
*/
|
||||||
|
public function add(array $parameters = []): void
|
||||||
|
{
|
||||||
|
$this->parameters = array_replace($this->parameters, $parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->parameters[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the parameter is defined.
|
||||||
|
*/
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return \array_key_exists($key, $this->parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a parameter.
|
||||||
|
*/
|
||||||
|
public function remove(string $key): void
|
||||||
|
{
|
||||||
|
unset($this->parameters[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the alphabetic characters of the parameter value.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||||
|
*/
|
||||||
|
public function getAlpha(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the alphabetic characters and digits of the parameter value.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||||
|
*/
|
||||||
|
public function getAlnum(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the digits of the parameter value.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||||
|
*/
|
||||||
|
public function getDigits(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter as string.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||||
|
*/
|
||||||
|
public function getString(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$value = $this->get($key, $default);
|
||||||
|
if (!\is_scalar($value) && !$value instanceof \Stringable) {
|
||||||
|
throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be converted to "string".', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter value converted to integer.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to integer
|
||||||
|
*/
|
||||||
|
public function getInt(string $key, int $default = 0): int
|
||||||
|
{
|
||||||
|
return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter value converted to boolean.
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the value cannot be converted to a boolean
|
||||||
|
*/
|
||||||
|
public function getBoolean(string $key, bool $default = false): bool
|
||||||
|
{
|
||||||
|
return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parameter value converted to an enum.
|
||||||
|
*
|
||||||
|
* @template T of \BackedEnum
|
||||||
|
*
|
||||||
|
* @param class-string<T> $class
|
||||||
|
* @param ?T $default
|
||||||
|
*
|
||||||
|
* @return ?T
|
||||||
|
*
|
||||||
|
* @psalm-return ($default is null ? T|null : T)
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the parameter value cannot be converted to an enum
|
||||||
|
*/
|
||||||
|
public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum
|
||||||
|
{
|
||||||
|
$value = $this->get($key);
|
||||||
|
|
||||||
|
if (null === $value) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $class::from($value);
|
||||||
|
} catch (\ValueError|\TypeError $e) {
|
||||||
|
throw new UnexpectedValueException(\sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter key.
|
||||||
|
*
|
||||||
|
* @param int $filter FILTER_* constant
|
||||||
|
* @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants
|
||||||
|
*
|
||||||
|
* @see https://php.net/filter-var
|
||||||
|
*
|
||||||
|
* @throws UnexpectedValueException if the parameter value is a non-stringable object
|
||||||
|
* @throws UnexpectedValueException if the parameter value is invalid and \FILTER_NULL_ON_FAILURE is not set
|
||||||
|
*/
|
||||||
|
public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed
|
||||||
|
{
|
||||||
|
$value = $this->get($key, $default);
|
||||||
|
|
||||||
|
// Always turn $options into an array - this allows filter_var option shortcuts.
|
||||||
|
if (!\is_array($options) && $options) {
|
||||||
|
$options = ['flags' => $options];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a convenience check for arrays.
|
||||||
|
if (\is_array($value) && !isset($options['flags'])) {
|
||||||
|
$options['flags'] = \FILTER_REQUIRE_ARRAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_object($value) && !$value instanceof \Stringable) {
|
||||||
|
throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be filtered.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$options['flags'] ??= 0;
|
||||||
|
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
|
||||||
|
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
|
||||||
|
|
||||||
|
$value = filter_var($value, $filter, $options);
|
||||||
|
|
||||||
|
if (null !== $value || $nullOnFailure) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an iterator for parameters.
|
||||||
|
*
|
||||||
|
* @return \ArrayIterator<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getIterator(): \ArrayIterator
|
||||||
|
{
|
||||||
|
return new \ArrayIterator($this->parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of parameters.
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return \count($this->parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
core/lib/Http/Request/RequestServerParameters.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerBag is a container for HTTP headers from the $_SERVER variable.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
|
||||||
|
* @author Robert Kiss <kepten@gmail.com>
|
||||||
|
*/
|
||||||
|
class RequestServerParameters extends RequestParameters
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Gets the HTTP headers.
|
||||||
|
*/
|
||||||
|
public function getHeaders(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($this->parameters as $key => $value) {
|
||||||
|
if (str_starts_with($key, 'HTTP_')) {
|
||||||
|
$headers[substr($key, 5)] = $value;
|
||||||
|
} elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) {
|
||||||
|
$headers[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->parameters['PHP_AUTH_USER'])) {
|
||||||
|
$headers['PHP_AUTH_USER'] = $this->parameters['PHP_AUTH_USER'];
|
||||||
|
$headers['PHP_AUTH_PW'] = $this->parameters['PHP_AUTH_PW'] ?? '';
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default
|
||||||
|
* For this workaround to work, add these lines to your .htaccess file:
|
||||||
|
* RewriteCond %{HTTP:Authorization} .+
|
||||||
|
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
|
||||||
|
*
|
||||||
|
* A sample .htaccess file:
|
||||||
|
* RewriteEngine On
|
||||||
|
* RewriteCond %{HTTP:Authorization} .+
|
||||||
|
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
|
||||||
|
* RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
* RewriteRule ^(.*)$ index.php [QSA,L]
|
||||||
|
*/
|
||||||
|
|
||||||
|
$authorizationHeader = null;
|
||||||
|
if (isset($this->parameters['HTTP_AUTHORIZATION'])) {
|
||||||
|
$authorizationHeader = $this->parameters['HTTP_AUTHORIZATION'];
|
||||||
|
} elseif (isset($this->parameters['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||||
|
$authorizationHeader = $this->parameters['REDIRECT_HTTP_AUTHORIZATION'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $authorizationHeader) {
|
||||||
|
if (0 === stripos($authorizationHeader, 'basic ')) {
|
||||||
|
// Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic
|
||||||
|
$exploded = explode(':', base64_decode(substr($authorizationHeader, 6)), 2);
|
||||||
|
if (2 == \count($exploded)) {
|
||||||
|
[$headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']] = $exploded;
|
||||||
|
}
|
||||||
|
} elseif (empty($this->parameters['PHP_AUTH_DIGEST']) && (0 === stripos($authorizationHeader, 'digest '))) {
|
||||||
|
// In some circumstances PHP_AUTH_DIGEST needs to be set
|
||||||
|
$headers['PHP_AUTH_DIGEST'] = $authorizationHeader;
|
||||||
|
$this->parameters['PHP_AUTH_DIGEST'] = $authorizationHeader;
|
||||||
|
} elseif (0 === stripos($authorizationHeader, 'bearer ')) {
|
||||||
|
/*
|
||||||
|
* XXX: Since there is no PHP_AUTH_BEARER in PHP predefined variables,
|
||||||
|
* I'll just set $headers['AUTHORIZATION'] here.
|
||||||
|
* https://php.net/reserved.variables.server
|
||||||
|
*/
|
||||||
|
$headers['AUTHORIZATION'] = $authorizationHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($headers['AUTHORIZATION'])) {
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP_AUTH_USER/PHP_AUTH_PW
|
||||||
|
if (isset($headers['PHP_AUTH_USER'])) {
|
||||||
|
$headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? ''));
|
||||||
|
} elseif (isset($headers['PHP_AUTH_DIGEST'])) {
|
||||||
|
$headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
core/lib/Http/Response/FileResponse.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple file response that reads a file from disk and serves it.
|
||||||
|
*
|
||||||
|
* Only supports sending full file contents (no range / streaming for now).
|
||||||
|
*/
|
||||||
|
class FileResponse extends Response
|
||||||
|
{
|
||||||
|
private string $filePath;
|
||||||
|
|
||||||
|
public function __construct(string $filePath, int $status = 200, array $headers = [])
|
||||||
|
{
|
||||||
|
if (!is_file($filePath) || !is_readable($filePath)) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('FileResponse: file not found or not readable: %s', $filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->filePath = $filePath;
|
||||||
|
|
||||||
|
// Determine content type (very small helper; rely on common extensions)
|
||||||
|
$mime = self::guessMimeType($filePath) ?? 'application/octet-stream';
|
||||||
|
$headers['Content-Type'] = $headers['Content-Type'] ?? $mime;
|
||||||
|
$headers['Content-Length'] = (string) filesize($filePath);
|
||||||
|
$headers['Last-Modified'] = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT';
|
||||||
|
$headers['Cache-Control'] = $headers['Cache-Control'] ?? 'public, max-age=60';
|
||||||
|
|
||||||
|
parent::__construct('', $status, $headers);
|
||||||
|
|
||||||
|
// Defer reading file until sendContent to avoid memory usage.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilePath(): string
|
||||||
|
{
|
||||||
|
return $this->filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendContent(): static
|
||||||
|
{
|
||||||
|
// Output file contents directly
|
||||||
|
readfile($this->filePath);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function guessMimeType(string $filePath): ?string
|
||||||
|
{
|
||||||
|
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
return match ($ext) {
|
||||||
|
'html', 'htm' => 'text/html; charset=UTF-8',
|
||||||
|
'css' => 'text/css; charset=UTF-8',
|
||||||
|
'js' => 'application/javascript; charset=UTF-8',
|
||||||
|
'json' => 'application/json; charset=UTF-8',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg', 'jpeg' => 'image/jpeg',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
'txt' => 'text/plain; charset=UTF-8',
|
||||||
|
'xml' => 'application/xml; charset=UTF-8',
|
||||||
|
default => self::finfoMime($filePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function finfoMime(string $filePath): ?string
|
||||||
|
{
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$f = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
if ($f) {
|
||||||
|
$mime = finfo_file($f, $filePath) ?: null;
|
||||||
|
finfo_close($f);
|
||||||
|
return $mime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
189
core/lib/Http/Response/JsonResponse.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response represents an HTTP response in JSON format.
|
||||||
|
*
|
||||||
|
* Note that this class does not force the returned JSON content to be an
|
||||||
|
* object. It is however recommended that you do return an object as it
|
||||||
|
* protects yourself against XSSI and JSON-JavaScript Hijacking.
|
||||||
|
*
|
||||||
|
* @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside
|
||||||
|
*
|
||||||
|
* @author Igor Wiedler <igor@wiedler.ch>
|
||||||
|
*/
|
||||||
|
class JsonResponse extends Response
|
||||||
|
{
|
||||||
|
protected mixed $data;
|
||||||
|
protected ?string $callback = null;
|
||||||
|
|
||||||
|
// Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML.
|
||||||
|
// 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
|
||||||
|
public const DEFAULT_ENCODING_OPTIONS = 15;
|
||||||
|
|
||||||
|
protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $json If the data is already a JSON string
|
||||||
|
*/
|
||||||
|
public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false)
|
||||||
|
{
|
||||||
|
parent::__construct('', $status, $headers);
|
||||||
|
|
||||||
|
if ($json && !\is_string($data) && !is_numeric($data) && !$data instanceof \Stringable) {
|
||||||
|
throw new \TypeError(\sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data ??= new \ArrayObject();
|
||||||
|
|
||||||
|
$json ? $this->setJson($data) : $this->setData($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method for chainability.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* return JsonResponse::fromJsonString('{"key": "value"}')
|
||||||
|
* ->setSharedMaxAge(300);
|
||||||
|
*
|
||||||
|
* @param string $data The JSON response string
|
||||||
|
* @param int $status The response status code (200 "OK" by default)
|
||||||
|
* @param array $headers An array of response headers
|
||||||
|
*/
|
||||||
|
public static function fromJsonString(string $data, int $status = 200, array $headers = []): static
|
||||||
|
{
|
||||||
|
return new static($data, $status, $headers, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the JSONP callback.
|
||||||
|
*
|
||||||
|
* @param string|null $callback The JSONP callback or null to use none
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException When the callback name is not valid
|
||||||
|
*/
|
||||||
|
public function setCallback(?string $callback): static
|
||||||
|
{
|
||||||
|
if (null !== $callback) {
|
||||||
|
// partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/
|
||||||
|
// partially taken from https://github.com/willdurand/JsonpCallbackValidator
|
||||||
|
// JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details.
|
||||||
|
// (c) William Durand <william.durand1@gmail.com>
|
||||||
|
$pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u';
|
||||||
|
$reserved = [
|
||||||
|
'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while',
|
||||||
|
'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export',
|
||||||
|
'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false',
|
||||||
|
];
|
||||||
|
$parts = explode('.', $callback);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) {
|
||||||
|
throw new \InvalidArgumentException('The callback name is not valid.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->callback = $callback;
|
||||||
|
|
||||||
|
return $this->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a raw string containing a JSON document to be sent.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setJson(string $json): static
|
||||||
|
{
|
||||||
|
$this->data = $json;
|
||||||
|
|
||||||
|
return $this->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the data to be sent as JSON.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function setData(mixed $data = []): static
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = json_encode($data, $this->encodingOptions);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) {
|
||||||
|
throw $e->getPrevious() ?: $e;
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\JSON_THROW_ON_ERROR & $this->encodingOptions) {
|
||||||
|
return $this->setJson($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\JSON_ERROR_NONE !== json_last_error()) {
|
||||||
|
throw new \InvalidArgumentException(json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->setJson($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns options used while encoding data to JSON.
|
||||||
|
*/
|
||||||
|
public function getEncodingOptions(): int
|
||||||
|
{
|
||||||
|
return $this->encodingOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets options used while encoding data to JSON.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setEncodingOptions(int $encodingOptions): static
|
||||||
|
{
|
||||||
|
$this->encodingOptions = $encodingOptions;
|
||||||
|
|
||||||
|
return $this->setData(json_decode($this->data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the content and headers according to the JSON data and callback.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
protected function update(): static
|
||||||
|
{
|
||||||
|
if (null !== $this->callback) {
|
||||||
|
// Not using application/javascript for compatibility reasons with older browsers.
|
||||||
|
$this->headers->set('Content-Type', 'text/javascript');
|
||||||
|
|
||||||
|
return $this->setContent(\sprintf('/**/%s(%s);', $this->callback, $this->data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback)
|
||||||
|
// in order to not overwrite a custom definition.
|
||||||
|
if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) {
|
||||||
|
$this->headers->set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->setContent($this->data);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
core/lib/Http/Response/RedirectResponse.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RedirectResponse represents an HTTP response doing a redirect.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*/
|
||||||
|
class RedirectResponse extends Response
|
||||||
|
{
|
||||||
|
protected string $targetUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a redirect response so that it conforms to the rules defined for a redirect status code.
|
||||||
|
*
|
||||||
|
* @param string $url The URL to redirect to. The URL should be a full URL, with schema etc.,
|
||||||
|
* but practically every browser redirects on paths only as well
|
||||||
|
* @param int $status The HTTP status code (302 "Found" by default)
|
||||||
|
* @param array $headers The headers (Location is always set to the given URL)
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*
|
||||||
|
* @see https://tools.ietf.org/html/rfc2616#section-10.3
|
||||||
|
*/
|
||||||
|
public function __construct(string $url, int $status = 302, array $headers = [])
|
||||||
|
{
|
||||||
|
parent::__construct('', $status, $headers);
|
||||||
|
|
||||||
|
$this->setTargetUrl($url);
|
||||||
|
|
||||||
|
if (!$this->isRedirect()) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('The HTTP status code is not a redirect ("%s" given).', $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (301 == $status && !\array_key_exists('cache-control', array_change_key_case($headers, \CASE_LOWER))) {
|
||||||
|
$this->headers->remove('cache-control');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the target URL.
|
||||||
|
*/
|
||||||
|
public function getTargetUrl(): string
|
||||||
|
{
|
||||||
|
return $this->targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the redirect target of this response.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function setTargetUrl(string $url): static
|
||||||
|
{
|
||||||
|
if ('' === $url) {
|
||||||
|
throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->targetUrl = $url;
|
||||||
|
|
||||||
|
$this->setContent(
|
||||||
|
\sprintf('<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="refresh" content="0;url=\'%1$s\'" />
|
||||||
|
|
||||||
|
<title>Redirecting to %1$s</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Redirecting to <a href="%1$s">%1$s</a>.
|
||||||
|
</body>
|
||||||
|
</html>', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8')));
|
||||||
|
|
||||||
|
$this->headers->set('Location', $url);
|
||||||
|
$this->headers->set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
1336
core/lib/Http/Response/Response.php
Normal file
275
core/lib/Http/Response/ResponseHeaderParameters.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
use KTXC\Http\Cookie;
|
||||||
|
use KTXC\Http\HeaderParameters;
|
||||||
|
use KTXC\Http\HeaderUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponseHeaderBag is a container for Response HTTP headers.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*/
|
||||||
|
class ResponseHeaderParameters extends HeaderParameters
|
||||||
|
{
|
||||||
|
public const COOKIES_FLAT = 'flat';
|
||||||
|
public const COOKIES_ARRAY = 'array';
|
||||||
|
|
||||||
|
public const DISPOSITION_ATTACHMENT = 'attachment';
|
||||||
|
public const DISPOSITION_INLINE = 'inline';
|
||||||
|
|
||||||
|
protected array $computedCacheControl = [];
|
||||||
|
protected array $cookies = [];
|
||||||
|
protected array $headerNames = [];
|
||||||
|
|
||||||
|
public function __construct(array $headers = [])
|
||||||
|
{
|
||||||
|
parent::__construct($headers);
|
||||||
|
|
||||||
|
if (!isset($this->headers['cache-control'])) {
|
||||||
|
$this->set('Cache-Control', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RFC2616 - 14.18 says all Responses need to have a Date */
|
||||||
|
if (!isset($this->headers['date'])) {
|
||||||
|
$this->initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the headers, with original capitalizations.
|
||||||
|
*/
|
||||||
|
public function allPreserveCase(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($this->all() as $name => $value) {
|
||||||
|
$headers[$this->headerNames[$name] ?? $name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allPreserveCaseWithoutCookies(): array
|
||||||
|
{
|
||||||
|
$headers = $this->allPreserveCase();
|
||||||
|
if (isset($this->headerNames['set-cookie'])) {
|
||||||
|
unset($headers[$this->headerNames['set-cookie']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replace(array $headers = []): void
|
||||||
|
{
|
||||||
|
$this->headerNames = [];
|
||||||
|
|
||||||
|
parent::replace($headers);
|
||||||
|
|
||||||
|
if (!isset($this->headers['cache-control'])) {
|
||||||
|
$this->set('Cache-Control', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($this->headers['date'])) {
|
||||||
|
$this->initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(?string $key = null): array
|
||||||
|
{
|
||||||
|
$headers = parent::all();
|
||||||
|
|
||||||
|
if (null !== $key) {
|
||||||
|
$key = strtr($key, self::UPPER, self::LOWER);
|
||||||
|
|
||||||
|
return 'set-cookie' !== $key ? $headers[$key] ?? [] : array_map('strval', $this->getCookies());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->getCookies() as $cookie) {
|
||||||
|
$headers['set-cookie'][] = (string) $cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, string|array|null $values, bool $replace = true): void
|
||||||
|
{
|
||||||
|
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
|
||||||
|
|
||||||
|
if ('set-cookie' === $uniqueKey) {
|
||||||
|
if ($replace) {
|
||||||
|
$this->cookies = [];
|
||||||
|
}
|
||||||
|
foreach ((array) $values as $cookie) {
|
||||||
|
$this->setCookie(Cookie::fromString($cookie));
|
||||||
|
}
|
||||||
|
$this->headerNames[$uniqueKey] = $key;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->headerNames[$uniqueKey] = $key;
|
||||||
|
|
||||||
|
parent::set($key, $values, $replace);
|
||||||
|
|
||||||
|
// ensure the cache-control header has sensible defaults
|
||||||
|
if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) {
|
||||||
|
$this->headers['cache-control'] = [$computed];
|
||||||
|
$this->headerNames['cache-control'] = 'Cache-Control';
|
||||||
|
$this->computedCacheControl = $this->parseCacheControl($computed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $key): void
|
||||||
|
{
|
||||||
|
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
|
||||||
|
unset($this->headerNames[$uniqueKey]);
|
||||||
|
|
||||||
|
if ('set-cookie' === $uniqueKey) {
|
||||||
|
$this->cookies = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::remove($key);
|
||||||
|
|
||||||
|
if ('cache-control' === $uniqueKey) {
|
||||||
|
$this->computedCacheControl = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('date' === $uniqueKey) {
|
||||||
|
$this->initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCacheControlDirective(string $key): bool
|
||||||
|
{
|
||||||
|
return \array_key_exists($key, $this->computedCacheControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCacheControlDirective(string $key): bool|string|null
|
||||||
|
{
|
||||||
|
return $this->computedCacheControl[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCookie(Cookie $cookie): void
|
||||||
|
{
|
||||||
|
$this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
|
||||||
|
$this->headerNames['set-cookie'] = 'Set-Cookie';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a cookie from the array, but does not unset it in the browser.
|
||||||
|
*/
|
||||||
|
public function removeCookie(string $name, ?string $path = '/', ?string $domain = null): void
|
||||||
|
{
|
||||||
|
$path ??= '/';
|
||||||
|
|
||||||
|
unset($this->cookies[$domain][$path][$name]);
|
||||||
|
|
||||||
|
if (empty($this->cookies[$domain][$path])) {
|
||||||
|
unset($this->cookies[$domain][$path]);
|
||||||
|
|
||||||
|
if (empty($this->cookies[$domain])) {
|
||||||
|
unset($this->cookies[$domain]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->cookies) {
|
||||||
|
unset($this->headerNames['set-cookie']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array with all cookies.
|
||||||
|
*
|
||||||
|
* @return Cookie[]
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException When the $format is invalid
|
||||||
|
*/
|
||||||
|
public function getCookies(string $format = self::COOKIES_FLAT): array
|
||||||
|
{
|
||||||
|
if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY])));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::COOKIES_ARRAY === $format) {
|
||||||
|
return $this->cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flattenedCookies = [];
|
||||||
|
foreach ($this->cookies as $path) {
|
||||||
|
foreach ($path as $cookies) {
|
||||||
|
foreach ($cookies as $cookie) {
|
||||||
|
$flattenedCookies[] = $cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $flattenedCookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a cookie in the browser.
|
||||||
|
*
|
||||||
|
* @param bool $partitioned
|
||||||
|
*/
|
||||||
|
public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */): void
|
||||||
|
{
|
||||||
|
$partitioned = 6 < \func_num_args() ? func_get_arg(6) : false;
|
||||||
|
|
||||||
|
$this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see HeaderUtils::makeDisposition()
|
||||||
|
*/
|
||||||
|
public function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string
|
||||||
|
{
|
||||||
|
return HeaderUtils::makeDisposition($disposition, $filename, $filenameFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the calculated value of the cache-control header.
|
||||||
|
*
|
||||||
|
* This considers several other headers and calculates or modifies the
|
||||||
|
* cache-control header to a sensible, conservative value.
|
||||||
|
*/
|
||||||
|
protected function computeCacheControlValue(): string
|
||||||
|
{
|
||||||
|
if (!$this->cacheControl) {
|
||||||
|
if ($this->has('Last-Modified') || $this->has('Expires')) {
|
||||||
|
return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
// conservative by default
|
||||||
|
return 'no-cache, private';
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = $this->getCacheControlHeader();
|
||||||
|
if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) {
|
||||||
|
return $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
// public if s-maxage is defined, private otherwise
|
||||||
|
if (!isset($this->cacheControl['s-maxage'])) {
|
||||||
|
return $header.', private';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function initDate(): void
|
||||||
|
{
|
||||||
|
$this->set('Date', gmdate('D, d M Y H:i:s').' GMT');
|
||||||
|
}
|
||||||
|
}
|
||||||
164
core/lib/Http/Response/StreamedJsonResponse.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StreamedJsonResponse represents a streamed HTTP response for JSON.
|
||||||
|
*
|
||||||
|
* A StreamedJsonResponse uses a structure and generics to create an
|
||||||
|
* efficient resource-saving JSON response.
|
||||||
|
*
|
||||||
|
* It is recommended to use flush() function after a specific number of items to directly stream the data.
|
||||||
|
*
|
||||||
|
* @see flush()
|
||||||
|
*
|
||||||
|
* @author Alexander Schranz <alexander@sulu.io>
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* function loadArticles(): \Generator
|
||||||
|
* // some streamed loading
|
||||||
|
* yield ['title' => 'Article 1'];
|
||||||
|
* yield ['title' => 'Article 2'];
|
||||||
|
* yield ['title' => 'Article 3'];
|
||||||
|
* // recommended to use flush() after every specific number of items
|
||||||
|
* }),
|
||||||
|
*
|
||||||
|
* $response = new StreamedJsonResponse(
|
||||||
|
* // json structure with generators in which will be streamed
|
||||||
|
* [
|
||||||
|
* '_embedded' => [
|
||||||
|
* 'articles' => loadArticles(), // any generator which you want to stream as list of data
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
class StreamedJsonResponse extends StreamedResponse
|
||||||
|
{
|
||||||
|
private const PLACEHOLDER = '__symfony_json__';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
|
||||||
|
* @param int $status The HTTP status code (200 "OK" by default)
|
||||||
|
* @param array<string, string|string[]> $headers An array of HTTP headers
|
||||||
|
* @param int $encodingOptions Flags for the json_encode() function
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly iterable $data,
|
||||||
|
int $status = 200,
|
||||||
|
array $headers = [],
|
||||||
|
private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
|
||||||
|
) {
|
||||||
|
parent::__construct($this->stream(...), $status, $headers);
|
||||||
|
|
||||||
|
if (!$this->headers->get('Content-Type')) {
|
||||||
|
$this->headers->set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stream(): void
|
||||||
|
{
|
||||||
|
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
|
||||||
|
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
|
||||||
|
|
||||||
|
$this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||||
|
{
|
||||||
|
if (\is_array($data)) {
|
||||||
|
$this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_iterable($data) && !$data instanceof \JsonSerializable) {
|
||||||
|
$this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($data, $jsonEncodingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||||
|
{
|
||||||
|
$generators = [];
|
||||||
|
|
||||||
|
array_walk_recursive($data, function (&$item, $key) use (&$generators) {
|
||||||
|
if (self::PLACEHOLDER === $key) {
|
||||||
|
// if the placeholder is already in the structure it should be replaced with a new one that explode
|
||||||
|
// works like expected for the structure
|
||||||
|
$generators[] = $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// generators should be used but for better DX all kind of Traversable and objects are supported
|
||||||
|
if (\is_object($item)) {
|
||||||
|
$generators[] = $item;
|
||||||
|
$item = self::PLACEHOLDER;
|
||||||
|
} elseif (self::PLACEHOLDER === $item) {
|
||||||
|
// if the placeholder is already in the structure it should be replaced with a new one that explode
|
||||||
|
// works like expected for the structure
|
||||||
|
$generators[] = $item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions));
|
||||||
|
|
||||||
|
foreach ($generators as $index => $generator) {
|
||||||
|
// send first and between parts of the structure
|
||||||
|
echo $jsonParts[$index];
|
||||||
|
|
||||||
|
$this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// send last part of the structure
|
||||||
|
echo $jsonParts[array_key_last($jsonParts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||||
|
{
|
||||||
|
$isFirstItem = true;
|
||||||
|
$startTag = '[';
|
||||||
|
|
||||||
|
foreach ($iterable as $key => $item) {
|
||||||
|
if ($isFirstItem) {
|
||||||
|
$isFirstItem = false;
|
||||||
|
// depending on the first elements key the generator is detected as a list or map
|
||||||
|
// we can not check for a whole list or map because that would hurt the performance
|
||||||
|
// of the streamed response which is the main goal of this response class
|
||||||
|
if (0 !== $key) {
|
||||||
|
$startTag = '{';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $startTag;
|
||||||
|
} else {
|
||||||
|
// if not first element of the generic, a separator is required between the elements
|
||||||
|
echo ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('{' === $startTag) {
|
||||||
|
echo json_encode((string) $key, $keyEncodingOptions).':';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isFirstItem) { // indicates that the generator was empty
|
||||||
|
echo '[';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '[' === $startTag ? ']' : '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
152
core/lib/Http/Response/StreamedResponse.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types = 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXC\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StreamedResponse represents a streamed HTTP response.
|
||||||
|
*
|
||||||
|
* A StreamedResponse uses a callback or an iterable of strings for its content.
|
||||||
|
*
|
||||||
|
* The callback should use the standard PHP functions like echo
|
||||||
|
* to stream the response back to the client. The flush() function
|
||||||
|
* can also be used if needed.
|
||||||
|
*
|
||||||
|
* @see flush()
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*/
|
||||||
|
class StreamedResponse extends Response
|
||||||
|
{
|
||||||
|
protected ?\Closure $callback = null;
|
||||||
|
protected bool $streamed = false;
|
||||||
|
|
||||||
|
private bool $headersSent = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|iterable<string>|null $callbackOrChunks
|
||||||
|
* @param int $status The HTTP status code (200 "OK" by default)
|
||||||
|
*/
|
||||||
|
public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = [])
|
||||||
|
{
|
||||||
|
parent::__construct(null, $status, $headers);
|
||||||
|
|
||||||
|
if (\is_callable($callbackOrChunks)) {
|
||||||
|
$this->setCallback($callbackOrChunks);
|
||||||
|
} elseif ($callbackOrChunks) {
|
||||||
|
$this->setChunks($callbackOrChunks);
|
||||||
|
}
|
||||||
|
$this->streamed = false;
|
||||||
|
$this->headersSent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<string> $chunks
|
||||||
|
*/
|
||||||
|
public function setChunks(iterable $chunks): static
|
||||||
|
{
|
||||||
|
$this->callback = static function () use ($chunks): void {
|
||||||
|
foreach ($chunks as $chunk) {
|
||||||
|
echo $chunk;
|
||||||
|
@ob_flush();
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the PHP callback associated with this Response.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setCallback(callable $callback): static
|
||||||
|
{
|
||||||
|
$this->callback = $callback(...);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCallback(): ?\Closure
|
||||||
|
{
|
||||||
|
if (!isset($this->callback)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->callback)(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method only sends the headers once.
|
||||||
|
*
|
||||||
|
* @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function sendHeaders(?int $statusCode = null): static
|
||||||
|
{
|
||||||
|
if ($this->headersSent) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statusCode < 100 || $statusCode >= 200) {
|
||||||
|
$this->headersSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::sendHeaders($statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method only sends the content once.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function sendContent(): static
|
||||||
|
{
|
||||||
|
if ($this->streamed) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->streamed = true;
|
||||||
|
|
||||||
|
if (!isset($this->callback)) {
|
||||||
|
throw new \LogicException('The Response callback must be set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
($this->callback)();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*
|
||||||
|
* @throws \LogicException when the content is not null
|
||||||
|
*/
|
||||||
|
public function setContent(?string $content): static
|
||||||
|
{
|
||||||
|
if (null !== $content) {
|
||||||
|
throw new \LogicException('The content cannot be set on a StreamedResponse instance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->streamed = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContent(): string|false
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
core/lib/Http/Session/SessionInterface.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for session storage.
|
||||||
|
*/
|
||||||
|
interface SessionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Starts the session storage.
|
||||||
|
*
|
||||||
|
* @return bool True if session started
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if session fails to start
|
||||||
|
*/
|
||||||
|
public function start(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the session ID.
|
||||||
|
*
|
||||||
|
* @return string The session ID
|
||||||
|
*/
|
||||||
|
public function getId(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the session ID.
|
||||||
|
*
|
||||||
|
* @param string $id The session ID
|
||||||
|
*/
|
||||||
|
public function setId(string $id): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the session name.
|
||||||
|
*
|
||||||
|
* @return string The session name
|
||||||
|
*/
|
||||||
|
public function getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the session name.
|
||||||
|
*
|
||||||
|
* @param string $name The session name
|
||||||
|
*/
|
||||||
|
public function setName(string $name): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the current session.
|
||||||
|
*
|
||||||
|
* Clears all session attributes and flashes and regenerates the
|
||||||
|
* session and deletes the old session from persistence.
|
||||||
|
*
|
||||||
|
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
|
||||||
|
* A null value will leave the system settings unchanged,
|
||||||
|
* 0 sets the cookie to expire with browser session.
|
||||||
|
* Time is in seconds, and is not a Unix timestamp.
|
||||||
|
*
|
||||||
|
* @return bool True if session invalidated, false if error
|
||||||
|
*/
|
||||||
|
public function invalidate(?int $lifetime = null): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the current session to a new session id while maintaining all
|
||||||
|
* session attributes.
|
||||||
|
*
|
||||||
|
* @param bool $destroy Whether to delete the old session or leave it to garbage collection
|
||||||
|
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
|
||||||
|
* A null value will leave the system settings unchanged,
|
||||||
|
* 0 sets the cookie to expire with browser session.
|
||||||
|
* Time is in seconds, and is not a Unix timestamp.
|
||||||
|
*
|
||||||
|
* @return bool True if session migrated, false if error
|
||||||
|
*/
|
||||||
|
public function migrate(bool $destroy = false, ?int $lifetime = null): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the session to be saved and closed.
|
||||||
|
*
|
||||||
|
* This method is generally not required for real sessions as
|
||||||
|
* the session will be automatically saved at the end of
|
||||||
|
* code execution.
|
||||||
|
*/
|
||||||
|
public function save(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an attribute is defined.
|
||||||
|
*
|
||||||
|
* @param string $name The attribute name
|
||||||
|
*
|
||||||
|
* @return bool True if the attribute is defined, false otherwise
|
||||||
|
*/
|
||||||
|
public function has(string $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an attribute.
|
||||||
|
*
|
||||||
|
* @param string $name The attribute name
|
||||||
|
* @param mixed $default The default value if not found
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function get(string $name, mixed $default = null): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an attribute.
|
||||||
|
*
|
||||||
|
* @param string $name The attribute name
|
||||||
|
* @param mixed $value The attribute value
|
||||||
|
*/
|
||||||
|
public function set(string $name, mixed $value): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns attributes.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Attributes
|
||||||
|
*/
|
||||||
|
public function all(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets attributes.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes Attributes
|
||||||
|
*/
|
||||||
|
public function replace(array $attributes): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an attribute.
|
||||||
|
*
|
||||||
|
* @param string $name The attribute name
|
||||||
|
*
|
||||||
|
* @return mixed The removed value or null when it does not exist
|
||||||
|
*/
|
||||||
|
public function remove(string $name): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all attributes.
|
||||||
|
*/
|
||||||
|
public function clear(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the session was started.
|
||||||
|
*
|
||||||
|
* @return bool True if started, false otherwise
|
||||||
|
*/
|
||||||
|
public function isStarted(): bool;
|
||||||
|
}
|
||||||
5
core/lib/Injection/Builder.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Injection;
|
||||||
|
|
||||||
|
class Builder extends \DI\ContainerBuilder {}
|
||||||
5
core/lib/Injection/Container.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Injection;
|
||||||
|
|
||||||
|
class Container extends \DI\Container {}
|
||||||
421
core/lib/Kernel.php
Normal 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;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
125
core/lib/Logger/FileLogger.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Logger;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\LogLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple file-based PSR-3 logger.
|
||||||
|
*/
|
||||||
|
class FileLogger implements LoggerInterface
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
private bool $useMicroseconds;
|
||||||
|
private string $channel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $logDir Directory where log files are written
|
||||||
|
* @param string $channel Logical channel name (used in filename)
|
||||||
|
* @param bool $useMicroseconds Whether to include microseconds in timestamp
|
||||||
|
*/
|
||||||
|
public function __construct(string $logDir, string $channel = 'app', bool $useMicroseconds = true)
|
||||||
|
{
|
||||||
|
$this->useMicroseconds = $useMicroseconds;
|
||||||
|
$this->channel = $channel;
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
}
|
||||||
|
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||||
|
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
|
||||||
|
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
|
||||||
|
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
|
||||||
|
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
|
||||||
|
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
|
||||||
|
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
|
||||||
|
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
|
||||||
|
|
||||||
|
public function log($level, $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$timestamp = $this->formatTimestamp();
|
||||||
|
$interpolated = $this->interpolate((string)$message, $context);
|
||||||
|
$payload = [
|
||||||
|
'time' => $timestamp,
|
||||||
|
'level' => strtolower((string)$level),
|
||||||
|
'channel' => $this->channel,
|
||||||
|
'message' => $interpolated,
|
||||||
|
'context' => $this->sanitizeContext($context),
|
||||||
|
];
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
// Fallback stringify if encoding fails (should be rare)
|
||||||
|
$json = json_encode([
|
||||||
|
'time' => $timestamp,
|
||||||
|
'level' => strtolower((string)$level),
|
||||||
|
'channel' => $this->channel,
|
||||||
|
'message' => $interpolated,
|
||||||
|
'context_error' => 'failed to encode context: '.json_last_error_msg(),
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
|
||||||
|
}
|
||||||
|
$this->write($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatTimestamp(): string
|
||||||
|
{
|
||||||
|
if ($this->useMicroseconds) {
|
||||||
|
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
|
||||||
|
return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
return date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpolate(string $message, array $context): string
|
||||||
|
{
|
||||||
|
if (!str_contains($message, '{')) {
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
$replace = [];
|
||||||
|
foreach ($context as $key => $val) {
|
||||||
|
if (is_array($val) || is_object($val)) {
|
||||||
|
continue; // don't inline complex values
|
||||||
|
}
|
||||||
|
$replace['{'.$key.'}'] = (string)$val;
|
||||||
|
}
|
||||||
|
return strtr($message, $replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeContext(array $context): array
|
||||||
|
{
|
||||||
|
if (empty($context)) { return []; }
|
||||||
|
$clean = [];
|
||||||
|
foreach ($context as $k => $v) {
|
||||||
|
if ($v instanceof \Throwable) {
|
||||||
|
$clean[$k] = [
|
||||||
|
'type' => get_class($v),
|
||||||
|
'message' => $v->getMessage(),
|
||||||
|
'code' => $v->getCode(),
|
||||||
|
'file' => $v->getFile(),
|
||||||
|
'line' => $v->getLine(),
|
||||||
|
'trace' => explode("\n", $v->getTraceAsString()),
|
||||||
|
];
|
||||||
|
} elseif (is_resource($v)) {
|
||||||
|
$clean[$k] = 'resource('.get_resource_type($v).')';
|
||||||
|
} elseif (is_object($v)) {
|
||||||
|
// Try to extract serializable data
|
||||||
|
if (method_exists($v, '__toString')) {
|
||||||
|
$clean[$k] = (string)$v;
|
||||||
|
} else {
|
||||||
|
$clean[$k] = ['object' => get_class($v)];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$clean[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(string $line): void
|
||||||
|
{
|
||||||
|
$line = rtrim($line)."\n"; // newline-delimited JSON (JSONL)
|
||||||
|
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
255
core/lib/Models/Firewall/FirewallLogObject.php
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Firewall;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonDeserializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a firewall access log entry for tracking blocked/allowed requests
|
||||||
|
*/
|
||||||
|
class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||||
|
{
|
||||||
|
public const RESULT_ALLOWED = 'allowed';
|
||||||
|
public const RESULT_BLOCKED = 'blocked';
|
||||||
|
|
||||||
|
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||||
|
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||||
|
public const EVENT_BRUTE_FORCE = 'brute_force';
|
||||||
|
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||||
|
public const EVENT_RULE_MATCH = 'rule_match';
|
||||||
|
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||||
|
|
||||||
|
private ?string $id = null;
|
||||||
|
private ?string $tenantId = null;
|
||||||
|
private ?string $ipAddress = null;
|
||||||
|
private ?string $deviceFingerprint = null;
|
||||||
|
private ?string $userAgent = null;
|
||||||
|
private ?string $requestPath = null;
|
||||||
|
private ?string $requestMethod = null;
|
||||||
|
private ?string $eventType = null;
|
||||||
|
private ?string $result = null; // allowed, blocked
|
||||||
|
private ?string $ruleId = null; // Which rule triggered (if any)
|
||||||
|
private ?string $identityId = null; // User ID if authenticated
|
||||||
|
private ?\DateTimeImmutable $timestamp = null;
|
||||||
|
private ?array $metadata = null; // Additional context
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('_id', $data)) {
|
||||||
|
$this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||||
|
} elseif (array_key_exists('id', $data)) {
|
||||||
|
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('tenantId', $data)) {
|
||||||
|
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('ipAddress', $data)) {
|
||||||
|
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('deviceFingerprint', $data)) {
|
||||||
|
$this->deviceFingerprint = $data['deviceFingerprint'] !== null ? (string)$data['deviceFingerprint'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('userAgent', $data)) {
|
||||||
|
$this->userAgent = $data['userAgent'] !== null ? (string)$data['userAgent'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('requestPath', $data)) {
|
||||||
|
$this->requestPath = $data['requestPath'] !== null ? (string)$data['requestPath'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('requestMethod', $data)) {
|
||||||
|
$this->requestMethod = $data['requestMethod'] !== null ? (string)$data['requestMethod'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('eventType', $data)) {
|
||||||
|
$this->eventType = $data['eventType'] !== null ? (string)$data['eventType'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('result', $data)) {
|
||||||
|
$this->result = $data['result'] !== null ? (string)$data['result'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('ruleId', $data)) {
|
||||||
|
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('identityId', $data)) {
|
||||||
|
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('timestamp', $data)) {
|
||||||
|
$this->timestamp = $data['timestamp'] !== null
|
||||||
|
? new \DateTimeImmutable($data['timestamp'])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('metadata', $data)) {
|
||||||
|
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'ipAddress' => $this->ipAddress,
|
||||||
|
'deviceFingerprint' => $this->deviceFingerprint,
|
||||||
|
'userAgent' => $this->userAgent,
|
||||||
|
'requestPath' => $this->requestPath,
|
||||||
|
'requestMethod' => $this->requestMethod,
|
||||||
|
'eventType' => $this->eventType,
|
||||||
|
'result' => $this->result,
|
||||||
|
'ruleId' => $this->ruleId,
|
||||||
|
'identityId' => $this->identityId,
|
||||||
|
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public function getId(): ?string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId(?string $id): self
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTenantId(): ?string
|
||||||
|
{
|
||||||
|
return $this->tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTenantId(?string $tenantId): self
|
||||||
|
{
|
||||||
|
$this->tenantId = $tenantId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIpAddress(?string $ipAddress): self
|
||||||
|
{
|
||||||
|
$this->ipAddress = $ipAddress;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceFingerprint(?string $deviceFingerprint): self
|
||||||
|
{
|
||||||
|
$this->deviceFingerprint = $deviceFingerprint;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserAgent(?string $userAgent): self
|
||||||
|
{
|
||||||
|
$this->userAgent = $userAgent;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequestPath(?string $requestPath): self
|
||||||
|
{
|
||||||
|
$this->requestPath = $requestPath;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequestMethod(?string $requestMethod): self
|
||||||
|
{
|
||||||
|
$this->requestMethod = $requestMethod;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEventType(): ?string
|
||||||
|
{
|
||||||
|
return $this->eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEventType(?string $eventType): self
|
||||||
|
{
|
||||||
|
$this->eventType = $eventType;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResult(): ?string
|
||||||
|
{
|
||||||
|
return $this->result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setResult(?string $result): self
|
||||||
|
{
|
||||||
|
$this->result = $result;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleId(): ?string
|
||||||
|
{
|
||||||
|
return $this->ruleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRuleId(?string $ruleId): self
|
||||||
|
{
|
||||||
|
$this->ruleId = $ruleId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdentityId(): ?string
|
||||||
|
{
|
||||||
|
return $this->identityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdentityId(?string $identityId): self
|
||||||
|
{
|
||||||
|
$this->identityId = $identityId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimestamp(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTimestamp(?\DateTimeImmutable $timestamp): self
|
||||||
|
{
|
||||||
|
$this->timestamp = $timestamp;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetadata(): ?array
|
||||||
|
{
|
||||||
|
return $this->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMetadata(?array $metadata): self
|
||||||
|
{
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
241
core/lib/Models/Firewall/FirewallRuleObject.php
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Firewall;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonDeserializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a firewall rule for IP/device access control
|
||||||
|
*/
|
||||||
|
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||||
|
{
|
||||||
|
public const TYPE_IP = 'ip';
|
||||||
|
public const TYPE_IP_RANGE = 'ip_range';
|
||||||
|
public const TYPE_DEVICE = 'device';
|
||||||
|
|
||||||
|
public const ACTION_ALLOW = 'allow';
|
||||||
|
public const ACTION_BLOCK = 'block';
|
||||||
|
|
||||||
|
private ?string $id = null;
|
||||||
|
private ?string $tenantId = null;
|
||||||
|
private ?string $type = null; // ip, ip_range, device
|
||||||
|
private ?string $action = null; // allow, block
|
||||||
|
private ?string $value = null; // IP address, CIDR range, or device fingerprint
|
||||||
|
private ?string $reason = null; // Why this rule was created
|
||||||
|
private ?string $createdBy = null; // User ID who created the rule
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
private ?\DateTimeImmutable $expiresAt = null; // null = permanent
|
||||||
|
private bool $enabled = true;
|
||||||
|
private ?array $metadata = null; // Additional context (user agent, country, etc.)
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('_id', $data)) {
|
||||||
|
$this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||||
|
} elseif (array_key_exists('id', $data)) {
|
||||||
|
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('tenantId', $data)) {
|
||||||
|
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('type', $data)) {
|
||||||
|
$this->type = $data['type'] !== null ? (string)$data['type'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('action', $data)) {
|
||||||
|
$this->action = $data['action'] !== null ? (string)$data['action'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('value', $data)) {
|
||||||
|
$this->value = $data['value'] !== null ? (string)$data['value'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('reason', $data)) {
|
||||||
|
$this->reason = $data['reason'] !== null ? (string)$data['reason'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('createdBy', $data)) {
|
||||||
|
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('createdAt', $data)) {
|
||||||
|
$this->createdAt = $data['createdAt'] !== null
|
||||||
|
? new \DateTimeImmutable($data['createdAt'])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('expiresAt', $data)) {
|
||||||
|
$this->expiresAt = $data['expiresAt'] !== null
|
||||||
|
? new \DateTimeImmutable($data['expiresAt'])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('enabled', $data)) {
|
||||||
|
$this->enabled = (bool)$data['enabled'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('metadata', $data)) {
|
||||||
|
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'type' => $this->type,
|
||||||
|
'action' => $this->action,
|
||||||
|
'value' => $this->value,
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'createdBy' => $this->createdBy,
|
||||||
|
'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM),
|
||||||
|
'expiresAt' => $this->expiresAt?->format(\DateTimeInterface::ATOM),
|
||||||
|
'enabled' => $this->enabled,
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this rule has expired
|
||||||
|
*/
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
if ($this->expiresAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->expiresAt < new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this rule is currently active (enabled and not expired)
|
||||||
|
*/
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled && !$this->isExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public function getId(): ?string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId(?string $id): self
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTenantId(): ?string
|
||||||
|
{
|
||||||
|
return $this->tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTenantId(?string $tenantId): self
|
||||||
|
{
|
||||||
|
$this->tenantId = $tenantId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): ?string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setType(?string $type): self
|
||||||
|
{
|
||||||
|
$this->type = $type;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAction(): ?string
|
||||||
|
{
|
||||||
|
return $this->action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAction(?string $action): self
|
||||||
|
{
|
||||||
|
$this->action = $action;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): ?string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setValue(?string $value): self
|
||||||
|
{
|
||||||
|
$this->value = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setReason(?string $reason): self
|
||||||
|
{
|
||||||
|
$this->reason = $reason;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedBy(): ?string
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedBy(?string $createdBy): self
|
||||||
|
{
|
||||||
|
$this->createdBy = $createdBy;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(?\DateTimeImmutable $createdAt): self
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiresAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExpiresAt(?\DateTimeImmutable $expiresAt): self
|
||||||
|
{
|
||||||
|
$this->expiresAt = $expiresAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $enabled): self
|
||||||
|
{
|
||||||
|
$this->enabled = $enabled;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetadata(): ?array
|
||||||
|
{
|
||||||
|
return $this->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMetadata(?array $metadata): self
|
||||||
|
{
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
core/lib/Models/Identity/User.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Identity;
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
private ?string $id = null;
|
||||||
|
private ?string $identity = null;
|
||||||
|
private ?string $label = null;
|
||||||
|
private ?array $roles = [];
|
||||||
|
private array $permissions = [];
|
||||||
|
private ?bool $enabled = null;
|
||||||
|
private ?string $provider = null;
|
||||||
|
private ?string $externalSubject = null;
|
||||||
|
private ?int $initialLogin = null;
|
||||||
|
private ?int $recentLogin = null;
|
||||||
|
|
||||||
|
public function populate(array $data, string $source): void
|
||||||
|
{
|
||||||
|
if ($source === 'users') {
|
||||||
|
$this->id = $data['uid'] ?? null; // 'uid' maps to 'id'
|
||||||
|
$this->identity = $data['identity'] ?? null;
|
||||||
|
$this->label = $data['label'] ?? null;
|
||||||
|
$this->roles = (array)$data['roles'] ?? [];
|
||||||
|
$this->enabled = $data['enabled'] ?? null;
|
||||||
|
$this->provider = $data['provider'] ?? null;
|
||||||
|
$this->externalSubject = $data['external_subject'] ?? null;
|
||||||
|
$this->initialLogin = $data['initial_login'] ?? null;
|
||||||
|
$this->recentLogin = $data['recent_login'] ?? null;
|
||||||
|
$this->permissions = (array)$data['permissions'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source === 'jwt') {
|
||||||
|
$this->id = $data['identifier'] ?? null;
|
||||||
|
$this->identity = $data['identity'] ?? null;
|
||||||
|
$this->label = $data['label'] ?? null;
|
||||||
|
$this->roles = (array)$data['role'] ?? [];
|
||||||
|
$this->permissions = (array)$data['permissions'] ?? [];
|
||||||
|
$this->enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source === 'external') {
|
||||||
|
$this->identity = $data['identity'] ?? null;
|
||||||
|
$this->label = $data['label'] ?? null;
|
||||||
|
$this->externalSubject = $data['external_subject'] ?? null;
|
||||||
|
$this->provider = $data['provider'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId(string $value): void
|
||||||
|
{
|
||||||
|
$this->id = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdentity(): ?string
|
||||||
|
{
|
||||||
|
return $this->identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdentity(string $value): void
|
||||||
|
{
|
||||||
|
$this->identity = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(?string $value): void
|
||||||
|
{
|
||||||
|
$this->label = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoles(): array
|
||||||
|
{
|
||||||
|
return $this->roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRoles(array $values): void
|
||||||
|
{
|
||||||
|
$this->roles = $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnabled(): ?bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(?bool $value): void
|
||||||
|
{
|
||||||
|
$this->enabled = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProvider(): ?string
|
||||||
|
{
|
||||||
|
return $this->provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProvider(?string $value): void
|
||||||
|
{
|
||||||
|
$this->provider = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExternalSubject(): ?string
|
||||||
|
{
|
||||||
|
return $this->externalSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExternalSubject(?string $value): void
|
||||||
|
{
|
||||||
|
$this->externalSubject = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInitialLogin(): ?int
|
||||||
|
{
|
||||||
|
return $this->initialLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInitialLogin(?int $value): void
|
||||||
|
{
|
||||||
|
$this->initialLogin = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecentLogin(): ?int
|
||||||
|
{
|
||||||
|
return $this->recentLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRecentLogin(?int $value): void
|
||||||
|
{
|
||||||
|
$this->recentLogin = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPermissions(): array
|
||||||
|
{
|
||||||
|
return $this->permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPermissions(array $permissions): void
|
||||||
|
{
|
||||||
|
$this->permissions = $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
return in_array($permission, $this->permissions, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
core/lib/Models/Tenant/DomainCollection.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use KTXF\Utile\Collection\CollectionAbstract;
|
||||||
|
|
||||||
|
class DomainCollection extends CollectionAbstract
|
||||||
|
{
|
||||||
|
public function __construct(array $items = [])
|
||||||
|
{
|
||||||
|
parent::__construct($items, CollectionAbstract::TYPE_STRING);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
core/lib/Models/Tenant/TenantAuthentication.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant Configuration
|
||||||
|
*/
|
||||||
|
class TenantAuthentication extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
protected array $providers = [];
|
||||||
|
protected int $methodsMinimal = 1;
|
||||||
|
|
||||||
|
public function providers(): array {
|
||||||
|
return $this->providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function methodsMinimal(): int {
|
||||||
|
return $this->methodsMinimal;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
core/lib/Models/Tenant/TenantCollection.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use KTXF\Utile\Collection\CollectionAbstract;
|
||||||
|
|
||||||
|
class TenantCollection extends CollectionAbstract
|
||||||
|
{
|
||||||
|
public function __construct(array $items = [])
|
||||||
|
{
|
||||||
|
parent::__construct($items, TenantObject::class, CollectionAbstract::TYPE_STRING);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
core/lib/Models/Tenant/TenantConfiguration.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant Configuration
|
||||||
|
*/
|
||||||
|
class TenantConfiguration extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
protected TenantAuthentication $authentication;
|
||||||
|
protected TenantSecurity $security;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->authentication = new TenantAuthentication();
|
||||||
|
$this->security = new TenantSecurity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authentication(): TenantAuthentication {
|
||||||
|
return $this->authentication;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function security(): TenantSecurity {
|
||||||
|
return $this->security;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
148
core/lib/Models/Tenant/TenantObject.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant entity representing a tenant
|
||||||
|
*/
|
||||||
|
class TenantObject extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
private ?string $id = null;
|
||||||
|
private ?string $identifier = null;
|
||||||
|
private bool $enabled = false;
|
||||||
|
private ?string $label = null;
|
||||||
|
private ?string $description = null;
|
||||||
|
private ?DomainCollection $domains = null;
|
||||||
|
private ?TenantConfiguration $configuration = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize from associative array.
|
||||||
|
*/
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
}
|
||||||
|
// Map only if key exists to avoid notices and allow partial input
|
||||||
|
if (array_key_exists('_id', $data)) $this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||||
|
elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||||
|
if (array_key_exists('identifier', $data)) $this->identifier = $data['identifier'] !== null ? (string)$data['identifier'] : null;
|
||||||
|
if (array_key_exists('enabled', $data)) $this->enabled = $data['enabled'] !== null ? (bool)$data['enabled'] : null;
|
||||||
|
if (array_key_exists('label', $data)) $this->label = $data['label'] !== null ? (string)$data['label'] : null;
|
||||||
|
if (array_key_exists('description', $data)) $this->description = $data['description'] !== null ? (string)$data['description'] : null;
|
||||||
|
if (array_key_exists('domains', $data)) {
|
||||||
|
$this->domains = (new DomainCollection((array)$data['domains']));
|
||||||
|
}
|
||||||
|
if (array_key_exists('configuration', $data)) {
|
||||||
|
$this->configuration = (new TenantConfiguration)->jsonDeserialize($data['configuration']);
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize to JSON-friendly structure.
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'identifier' => $this->identifier,
|
||||||
|
'enabled' => $this->enabled,
|
||||||
|
'label' => $this->label,
|
||||||
|
'description' => $this->description,
|
||||||
|
'domains' => $this->domains,
|
||||||
|
'configuration' => $this->configuration,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId(string $value): self
|
||||||
|
{
|
||||||
|
$this->id = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdentifier(): ?string
|
||||||
|
{
|
||||||
|
return $this->identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdentifier(string $value): self
|
||||||
|
{
|
||||||
|
$this->identifier = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $value): self
|
||||||
|
{
|
||||||
|
$this->enabled = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $value): self
|
||||||
|
{
|
||||||
|
$this->label = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(string $value): self
|
||||||
|
{
|
||||||
|
$this->description = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDomains(): ?DomainCollection
|
||||||
|
{
|
||||||
|
return $this->domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDomains(DomainCollection $value): self
|
||||||
|
{
|
||||||
|
$this->domains = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfiguration(): TenantConfiguration
|
||||||
|
{
|
||||||
|
return $this->configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConfiguration(TenantConfiguration $value): self
|
||||||
|
{
|
||||||
|
$this->configuration = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettings(): array
|
||||||
|
{
|
||||||
|
return $this->configuration['settings'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSettings(array $value): self
|
||||||
|
{
|
||||||
|
$this->configuration['settings'] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
core/lib/Models/Tenant/TenantSecurity.php
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
core/lib/Module/ModuleAutoloader.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
core/lib/Module/ModuleCollection.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Module;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
use KTXF\Utile\Collection\CollectionAbstract;
|
||||||
|
|
||||||
|
class ModuleCollection extends CollectionAbstract implements JsonSerializable
|
||||||
|
{
|
||||||
|
public function __construct(array $items = [])
|
||||||
|
{
|
||||||
|
parent::__construct($items, ModuleObject::class, CollectionAbstract::TYPE_STRING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($this as $key => $item) {
|
||||||
|
$result[$key] = $item;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
464
core/lib/Module/ModuleManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
161
core/lib/Module/ModuleObject.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
119
core/lib/Module/Store/ModuleEntry.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Module\Store;
|
||||||
|
|
||||||
|
use KTXF\Json\JsonDeserializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module entity representing an installed module
|
||||||
|
*/
|
||||||
|
class ModuleEntry implements \JsonSerializable, JsonDeserializable
|
||||||
|
{
|
||||||
|
private ?string $id = null;
|
||||||
|
private ?string $namespace = null;
|
||||||
|
private ?string $handle = null;
|
||||||
|
private bool $installed = false;
|
||||||
|
private bool $enabled = false;
|
||||||
|
private string $version = '0.0.1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize from associative array.
|
||||||
|
*/
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
}
|
||||||
|
// Map only if key exists to avoid notices and allow partial input
|
||||||
|
if (array_key_exists('_id', $data)) $this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||||
|
elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||||
|
if (array_key_exists('namespace', $data)) $this->namespace = $data['namespace'] !== null ? (string)$data['namespace'] : null;
|
||||||
|
if (array_key_exists('handle', $data)) $this->handle = $data['handle'] !== null ? (string)$data['handle'] : null;
|
||||||
|
if (array_key_exists('installed', $data)) $this->installed = (bool)$data['installed'];
|
||||||
|
if (array_key_exists('enabled', $data)) $this->enabled = (bool)$data['enabled'];
|
||||||
|
if (array_key_exists('version', $data)) $this->version = (string)$data['version'];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize to JSON-friendly structure.
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'namespace' => $this->namespace,
|
||||||
|
'handle' => $this->handle,
|
||||||
|
'installed' => $this->installed,
|
||||||
|
'enabled' => $this->enabled,
|
||||||
|
'version' => $this->version,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId(string $value): self
|
||||||
|
{
|
||||||
|
$this->id = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNamespace(): ?string
|
||||||
|
{
|
||||||
|
return $this->namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNamespace(string $value): self
|
||||||
|
{
|
||||||
|
$this->namespace = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHandle(): ?string
|
||||||
|
{
|
||||||
|
return $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHandle(string $value): self
|
||||||
|
{
|
||||||
|
$this->handle = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInstalled(): bool
|
||||||
|
{
|
||||||
|
return $this->installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInstalled(bool $value): self
|
||||||
|
{
|
||||||
|
$this->installed = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $value): self
|
||||||
|
{
|
||||||
|
$this->enabled = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): string
|
||||||
|
{
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setVersion(string $value): self
|
||||||
|
{
|
||||||
|
$this->version = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
core/lib/Module/Store/ModuleStore.php
Normal file
@@ -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
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
core/lib/Resource/ProviderManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
29
core/lib/Routing/Route.php
Normal 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
@@ -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;
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
180
core/lib/Security/Authentication/AuthenticationRequest.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Authentication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Request
|
||||||
|
*
|
||||||
|
* Request DTO from controller to AuthenticationManager.
|
||||||
|
* Encapsulates all input data for authentication operations.
|
||||||
|
*/
|
||||||
|
readonly class AuthenticationRequest
|
||||||
|
{
|
||||||
|
// Action types
|
||||||
|
public const ACTION_START = 'start';
|
||||||
|
public const ACTION_IDENTIFY = 'identify';
|
||||||
|
public const ACTION_VERIFY = 'verify';
|
||||||
|
public const ACTION_CHALLENGE = 'challenge';
|
||||||
|
public const ACTION_REDIRECT = 'redirect';
|
||||||
|
public const ACTION_CALLBACK = 'callback';
|
||||||
|
public const ACTION_STATUS = 'status';
|
||||||
|
public const ACTION_CANCEL = 'cancel';
|
||||||
|
public const ACTION_REFRESH = 'refresh';
|
||||||
|
public const ACTION_LOGOUT = 'logout';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** Action to perform */
|
||||||
|
public string $action,
|
||||||
|
|
||||||
|
/** Session ID (for ongoing auth flows) */
|
||||||
|
public ?string $sessionId = null,
|
||||||
|
|
||||||
|
/** User identity (email/username) */
|
||||||
|
public ?string $identity = null,
|
||||||
|
|
||||||
|
/** Authentication method/provider ID */
|
||||||
|
public ?string $method = null,
|
||||||
|
|
||||||
|
/** Secret/code/password */
|
||||||
|
public ?string $secret = null,
|
||||||
|
|
||||||
|
/** Callback URL for redirect flows */
|
||||||
|
public ?string $callbackUrl = null,
|
||||||
|
|
||||||
|
/** Return URL after authentication */
|
||||||
|
public ?string $returnUrl = null,
|
||||||
|
|
||||||
|
/** Additional parameters (OIDC callback params, etc.) */
|
||||||
|
public array $params = [],
|
||||||
|
|
||||||
|
/** Token for refresh/logout operations */
|
||||||
|
public ?string $token = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Factory Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a start request
|
||||||
|
*/
|
||||||
|
public static function start(): self
|
||||||
|
{
|
||||||
|
return new self(action: self::ACTION_START);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an identify request
|
||||||
|
*/
|
||||||
|
public static function identify(string $sessionId, string $identity): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_IDENTIFY,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
identity: $identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a verify request (password, TOTP code, etc.)
|
||||||
|
*/
|
||||||
|
public static function verify(string $sessionId, string $method, string $secret): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_VERIFY,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
method: $method,
|
||||||
|
secret: $secret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a begin challenge request
|
||||||
|
*/
|
||||||
|
public static function challenge(string $sessionId, string $method): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_CHALLENGE,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
method: $method,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a begin redirect request
|
||||||
|
*/
|
||||||
|
public static function redirect(
|
||||||
|
string $sessionId,
|
||||||
|
string $method,
|
||||||
|
string $callbackUrl,
|
||||||
|
?string $returnUrl = null
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_REDIRECT,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
method: $method,
|
||||||
|
callbackUrl: $callbackUrl,
|
||||||
|
returnUrl: $returnUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a callback request (OIDC/SAML return)
|
||||||
|
*/
|
||||||
|
public static function callback(string $sessionId, string $method, array $params): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_CALLBACK,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
method: $method,
|
||||||
|
params: $params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a status request
|
||||||
|
*/
|
||||||
|
public static function status(string $sessionId): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_STATUS,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a cancel request
|
||||||
|
*/
|
||||||
|
public static function cancel(string $sessionId): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_CANCEL,
|
||||||
|
sessionId: $sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a refresh token request
|
||||||
|
*/
|
||||||
|
public static function refresh(string $token): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_REFRESH,
|
||||||
|
token: $token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a logout request
|
||||||
|
*/
|
||||||
|
public static function logout(?string $token = null, bool $allDevices = false): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
action: self::ACTION_LOGOUT,
|
||||||
|
token: $token,
|
||||||
|
params: ['all_devices' => $allDevices],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
core/lib/Security/Authentication/AuthenticationResponse.php
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
793
core/lib/Security/AuthenticationManager.php
Normal 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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
228
core/lib/Service/ConfigurationService.php
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
use KTXC\Db\DataStore;
|
||||||
|
use KTXC\Db\Collection;
|
||||||
|
use KTXC\Db\UTCDateTime;
|
||||||
|
use KTXC\SessionTenant;
|
||||||
|
|
||||||
|
class ConfigurationService
|
||||||
|
{
|
||||||
|
// Service constants
|
||||||
|
private const TABLE_NAME = 'system_configuration';
|
||||||
|
// Type constants for configuration values
|
||||||
|
public const TYPE_NULL = 0;
|
||||||
|
public const TYPE_STRING = 1;
|
||||||
|
public const TYPE_INTEGER = 2;
|
||||||
|
public const TYPE_FLOAT = 3;
|
||||||
|
public const TYPE_BOOLEAN = 4;
|
||||||
|
public const TYPE_ARRAY = 5;
|
||||||
|
public const TYPE_JSON = 6;
|
||||||
|
|
||||||
|
private Collection $collection;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
DataStore $store,
|
||||||
|
private readonly SessionTenant $tenant
|
||||||
|
) {
|
||||||
|
// DataStore provides selectCollection method
|
||||||
|
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
||||||
|
$this->collection->createIndex(['did' => 1, 'path' => 1, 'key' => 1], ['unique' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a configuration value by path and key
|
||||||
|
*/
|
||||||
|
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||||
|
if (!$doc) { return $default; }
|
||||||
|
$value = $doc['value'] ?? ($doc['default'] ?? null);
|
||||||
|
if ($value === null) { return $default; }
|
||||||
|
return $this->convertFromDatabase((string)$value, (int)$doc['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a configuration value
|
||||||
|
*/
|
||||||
|
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $this->determineType($value);
|
||||||
|
$serializedValue = $this->convertToDatabase($value, $type);
|
||||||
|
$serializedDefault = $default !== null ? $this->convertToDatabase($default, $type) : null;
|
||||||
|
$this->collection->updateOne(
|
||||||
|
['did' => $tenant, 'path' => $path, 'key' => $key],
|
||||||
|
['$set' => [
|
||||||
|
'did' => $tenant,
|
||||||
|
'path' => $path,
|
||||||
|
'key' => $key,
|
||||||
|
'value' => $serializedValue,
|
||||||
|
'type' => $type,
|
||||||
|
'default' => $serializedDefault,
|
||||||
|
'updated_at' => $this->bsonUtcDateTime()
|
||||||
|
], '$setOnInsert' => [ 'created_at' => $this->bsonUtcDateTime() ]],
|
||||||
|
['upsert' => true]
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all configuration values for a specific path
|
||||||
|
*/
|
||||||
|
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = ['did' => $tenant];
|
||||||
|
if ($path !== null) {
|
||||||
|
if ($subset) {
|
||||||
|
$filter['$or'] = [
|
||||||
|
['path' => $path],
|
||||||
|
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$filter['path'] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cursor = $this->collection->find($filter);
|
||||||
|
$configurations = [];
|
||||||
|
foreach ($cursor as $doc) {
|
||||||
|
$value = $doc['value'] ?? ($doc['default'] ?? null);
|
||||||
|
$convertedValue = $value !== null ? $this->convertFromDatabase((string)$value, (int)$doc['type']) : null;
|
||||||
|
$configurations[$doc['path']] = [$doc['key'] => $convertedValue];
|
||||||
|
}
|
||||||
|
return $configurations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a configuration value
|
||||||
|
*/
|
||||||
|
public function delete(string $path, string $key, ?string $tenant = null): bool
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all configuration values for a specific path
|
||||||
|
*/
|
||||||
|
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = ['did' => $tenant];
|
||||||
|
if ($includeSubPaths) {
|
||||||
|
$filter['$or'] = [
|
||||||
|
['path' => $path],
|
||||||
|
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$filter['path'] = $path;
|
||||||
|
}
|
||||||
|
$this->collection->deleteMany($filter);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a configuration exists
|
||||||
|
*/
|
||||||
|
public function exists(string $path, string $key, ?string $tenant = null): bool
|
||||||
|
{
|
||||||
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
|
} elseif ($tenant === null) {
|
||||||
|
$tenant = $this->tenant->identifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the type of a PHP value
|
||||||
|
*/
|
||||||
|
private function determineType(mixed $value): int
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_null($value) => self::TYPE_NULL,
|
||||||
|
is_bool($value) => self::TYPE_BOOLEAN,
|
||||||
|
is_int($value) => self::TYPE_INTEGER,
|
||||||
|
is_float($value) => self::TYPE_FLOAT,
|
||||||
|
is_array($value) => self::TYPE_ARRAY,
|
||||||
|
is_string($value) && $this->isJson($value) => self::TYPE_JSON,
|
||||||
|
default => self::TYPE_STRING
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a PHP value to database format
|
||||||
|
*/
|
||||||
|
private function convertToDatabase(mixed $value, int $type): string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
self::TYPE_NULL => '',
|
||||||
|
self::TYPE_BOOLEAN => $value ? '1' : '0',
|
||||||
|
self::TYPE_INTEGER => (string)$value,
|
||||||
|
self::TYPE_FLOAT => (string)$value,
|
||||||
|
self::TYPE_ARRAY, self::TYPE_JSON => json_encode($value),
|
||||||
|
default => (string)$value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a database value to PHP format
|
||||||
|
*/
|
||||||
|
private function convertFromDatabase(string $value, int $type): mixed
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
self::TYPE_NULL => null,
|
||||||
|
self::TYPE_BOOLEAN => $value === '1',
|
||||||
|
self::TYPE_INTEGER => (int)$value,
|
||||||
|
self::TYPE_FLOAT => (float)$value,
|
||||||
|
self::TYPE_ARRAY, self::TYPE_JSON => json_decode($value, true),
|
||||||
|
default => $value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is valid JSON
|
||||||
|
*/
|
||||||
|
private function isJson(string $string): bool
|
||||||
|
{
|
||||||
|
json_decode($string);
|
||||||
|
return json_last_error() === JSON_ERROR_NONE;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a UTCDateTime for timestamp fields
|
||||||
|
*/
|
||||||
|
private function bsonUtcDateTime(): UTCDateTime
|
||||||
|
{
|
||||||
|
return UTCDateTime::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
630
core/lib/Service/FirewallService.php
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
core/lib/Service/SecurityService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
core/lib/Service/TenantService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
|
use KTXC\Stores\TenantStore;
|
||||||
|
|
||||||
|
class TenantService
|
||||||
|
{
|
||||||
|
public function __construct(protected readonly TenantStore $store)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchByDomain(string $domain): ?TenantObject
|
||||||
|
{
|
||||||
|
return $this->store->fetchByDomain($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
309
core/lib/Service/TokenService.php
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
core/lib/Service/UserManagerService.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
278
core/lib/Service/UserProvisioningService.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
47
core/lib/Service/UserService.php
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
64
core/lib/SessionIdentity.php
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
core/lib/Stores/ExternalIdentityStore.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
309
core/lib/Stores/FirewallStore.php
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Stores;
|
||||||
|
|
||||||
|
use KTXC\Db\DataStore;
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for firewall rules and access logs
|
||||||
|
*/
|
||||||
|
class FirewallStore
|
||||||
|
{
|
||||||
|
protected const RULES_COLLECTION = 'firewall_rules';
|
||||||
|
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected readonly DataStore $dataStore
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Rule Operations
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all rules for a tenant
|
||||||
|
*/
|
||||||
|
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||||
|
{
|
||||||
|
$filter = ['tenantId' => $tenantId];
|
||||||
|
|
||||||
|
if ($activeOnly) {
|
||||||
|
$filter['enabled'] = true;
|
||||||
|
$filter['$or'] = [
|
||||||
|
['expiresAt' => null],
|
||||||
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
$list[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find rules by IP address
|
||||||
|
*/
|
||||||
|
public function findRulesByIp(string $tenantId, string $ipAddress): array
|
||||||
|
{
|
||||||
|
$filter = [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]],
|
||||||
|
'enabled' => true,
|
||||||
|
'$or' => [
|
||||||
|
['expiresAt' => null],
|
||||||
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
$list[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find rules by device fingerprint
|
||||||
|
*/
|
||||||
|
public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array
|
||||||
|
{
|
||||||
|
$filter = [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'type' => FirewallRuleObject::TYPE_DEVICE,
|
||||||
|
'value' => $deviceFingerprint,
|
||||||
|
'enabled' => true,
|
||||||
|
'$or' => [
|
||||||
|
['expiresAt' => null],
|
||||||
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
$list[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific rule by ID
|
||||||
|
*/
|
||||||
|
public function fetchRule(string $id): ?FirewallRuleObject
|
||||||
|
{
|
||||||
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||||
|
if (!$entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if exact IP rule exists
|
||||||
|
*/
|
||||||
|
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
||||||
|
{
|
||||||
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'type' => FirewallRuleObject::TYPE_IP,
|
||||||
|
'value' => $ipAddress,
|
||||||
|
'action' => $action,
|
||||||
|
'enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a rule
|
||||||
|
*/
|
||||||
|
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
|
{
|
||||||
|
if ($rule->getId()) {
|
||||||
|
return $this->updateRule($rule);
|
||||||
|
} else {
|
||||||
|
return $this->createRule($rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
|
{
|
||||||
|
$data = $rule->jsonSerialize();
|
||||||
|
unset($data['id']); // Remove id for insert
|
||||||
|
|
||||||
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
||||||
|
$rule->setId((string)$result->getInsertedId());
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
|
{
|
||||||
|
$id = $rule->getId();
|
||||||
|
if (!$id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $rule->jsonSerialize();
|
||||||
|
unset($data['id']);
|
||||||
|
|
||||||
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||||
|
['_id' => $id],
|
||||||
|
['$set' => $data]
|
||||||
|
);
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a rule
|
||||||
|
*/
|
||||||
|
public function destroyRule(FirewallRuleObject $rule): void
|
||||||
|
{
|
||||||
|
$id = $rule->getId();
|
||||||
|
if (!$id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete expired rules
|
||||||
|
*/
|
||||||
|
public function cleanupExpiredRules(): int
|
||||||
|
{
|
||||||
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||||
|
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||||
|
'expiresAt' => ['$ne' => null]
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result->getDeletedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Log Operations
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a firewall event
|
||||||
|
*/
|
||||||
|
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||||
|
{
|
||||||
|
$data = $log->jsonSerialize();
|
||||||
|
unset($data['id']);
|
||||||
|
|
||||||
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||||
|
$log->setId((string)$result->getInsertedId());
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs for a tenant with optional filters
|
||||||
|
*/
|
||||||
|
public function listLogs(
|
||||||
|
string $tenantId,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $eventType = null,
|
||||||
|
?string $result = null,
|
||||||
|
int $limit = 100,
|
||||||
|
int $offset = 0
|
||||||
|
): array {
|
||||||
|
$filter = ['tenantId' => $tenantId];
|
||||||
|
|
||||||
|
if ($ipAddress !== null) {
|
||||||
|
$filter['ipAddress'] = $ipAddress;
|
||||||
|
}
|
||||||
|
if ($eventType !== null) {
|
||||||
|
$filter['eventType'] = $eventType;
|
||||||
|
}
|
||||||
|
if ($result !== null) {
|
||||||
|
$filter['result'] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find(
|
||||||
|
$filter,
|
||||||
|
[
|
||||||
|
'sort' => ['timestamp' => -1],
|
||||||
|
'limit' => $limit,
|
||||||
|
'skip' => $offset
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$log = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
||||||
|
$list[] = $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count recent failures from an IP within a time window
|
||||||
|
*/
|
||||||
|
public function countRecentFailures(
|
||||||
|
string $tenantId,
|
||||||
|
string $ipAddress,
|
||||||
|
int $windowSeconds = 300
|
||||||
|
): int {
|
||||||
|
$since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds");
|
||||||
|
|
||||||
|
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'ipAddress' => $ipAddress,
|
||||||
|
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||||
|
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blocked requests count for dashboard
|
||||||
|
*/
|
||||||
|
public function countBlockedRequests(
|
||||||
|
string $tenantId,
|
||||||
|
?\DateTimeImmutable $since = null
|
||||||
|
): int {
|
||||||
|
$filter = [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'result' => FirewallLogObject::RESULT_BLOCKED
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($since !== null) {
|
||||||
|
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old logs
|
||||||
|
*/
|
||||||
|
public function cleanupOldLogs(int $daysToKeep = 30): int
|
||||||
|
{
|
||||||
|
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
||||||
|
|
||||||
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
||||||
|
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result->getDeletedCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
75
core/lib/Stores/TenantStore.php
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
257
core/lib/Stores/UserStore.php
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView></RouterView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router';
|
||||||
|
</script>
|
||||||
16
core/src/assets/images/favicon.svg
Normal 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 |
BIN
core/src/assets/images/maintenance/Error404.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
core/src/assets/images/maintenance/Error500.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
core/src/assets/images/maintenance/TwoCone.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
core/src/assets/images/maintenance/coming-soon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
353
core/src/assets/images/maintenance/under-construction.svg
Normal 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 |
BIN
core/src/assets/images/users/avatar-1.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
core/src/assets/images/users/avatar-2.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
core/src/assets/images/users/avatar-3.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
core/src/assets/images/users/avatar-4.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
core/src/assets/images/users/avatar-5.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |