commit 951ac7ee2036f4fea72abd22204571496c7ae0b2 Author: Robert Date: Tue Jan 6 23:53:09 2026 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cee46cc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +src/vendor diff --git a/README.md b/README.md new file mode 100644 index 0000000..5095dd3 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# IOcornerstone PHP 8.5 Framework + +Author Robert Strutts +Copyright (c) 2010-2026 MIT diff --git a/docs/notes.txt b/docs/notes.txt new file mode 100644 index 0000000..8205fef --- /dev/null +++ b/docs/notes.txt @@ -0,0 +1,6 @@ +Element Case Style +Properties camelCase +Methods camelCase +Variables camelCase +Constants UPPER_SNAKE_CASE +Classes PascalCase diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..eca37a1 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,87 @@ +register(); +$loader->addNamespace("IOcornerstone", IO_CORNERSTONE_FRAMEWORK); +if (defined("IO_CORNERSTONE_PROJECT")) { + $loader->addNamespace("Project", IO_CORNERSTONE_PROJECT); +} +define("PSR", IO_CORNERSTONE_FRAMEWORK . 'vendor' . DIRECTORY_SEPARATOR . 'psr' . DIRECTORY_SEPARATOR); +$loader->addNamespace("Psr\Log", PSR . 'log' . DIRECTORY_SEPARATOR . 'src'); +$loader->addNamespace("Psr\Container", PSR . 'container' . DIRECTORY_SEPARATOR . 'src'); +$loader->addNamespace("Psr\Http\Message", PSR . 'http-message' . DIRECTORY_SEPARATOR . 'src'); +$loader->addNamespace("Psr\Http\Server", [ + PSR . 'http-server-middleware' . DIRECTORY_SEPARATOR . 'src', + PSR . 'http-server-handler' . DIRECTORY_SEPARATOR . 'src' +]); + +function dd($var = 'nothing', endDump $end = endDump::EXIT_AND_STOP) +{ + Common::dump($var, $end); +} + +function dump($var = 'nothing', endDump $end = endDump::KEEP_WORKING) +{ + Common::dump($var, $end); +} + +$debug = true; // false in production +$myErrorHandler = new ErrorHandler($debug); +$myErrorHandler->register(); + +Reg::set('loader', $loader); +Reg::set('di', new DI()); // Initialize our Dependency Injector +Reg::set('container', new AutowireContainer()); + +Console::setupConsoleVars(); // Copy CLI Args into $_GET + +if (defined("IO_CORNERSTONE_PROJECT")) { + LoadAll::init(IO_CORNERSTONE_PROJECT); // Load Configs and Services +} + +function isLive(): bool +{ + if (Configure::has('IOcornerstone', 'live')) { + $live = Configure::get('IOcornerstone', 'live'); + } + if ($live === null) { + $live = true; + } + return (bool) $live; +} + +if (Console::isConsole()) { + CliDefaults::init(); +} + +$kernel = new Kernel()->run(); \ No newline at end of file diff --git a/src/Framework/App.php b/src/Framework/App.php new file mode 100644 index 0000000..cfa4832 --- /dev/null +++ b/src/Framework/App.php @@ -0,0 +1,248 @@ +request = $request; + $ret = $this->startUp(); + if ($ret === false) { + return $this->local404(); + } + return $ret; + } + + public function getMiddleware() + { + return $this->middleWare; + } + + private function GetWithoutFileExtension(string $route): string + { + $dot_position = strpos($route, '.'); + if ($dot_position !== false) { + $offset = strlen($route) - $dot_position; + $ext = Common::getStringRight($route, $offset); + if ($ext === ".php") { + return $this->local404(); + } + return substr($route, 0, $dot_position); + } + return $route; // No dot found, return full string + } + + private function getFirstChunks(string $input): string + { + $parts = explode('/', $input); + $last = array_pop($parts); + $parts = array(implode('/', $parts), $last); + return $parts[0]; + } + + private function getLastPart(string $input): string + { + $reversedParts = explode('/', strrev($input), 2); + return strrev($reversedParts[0]); + } + + /** + * Do not declare a return type here, as it will Error out!! + */ + private function startUp() + { + $full_route = $this->request->getUri()->getPath(); + $noFileExt = $this->GetWithoutFileExtension($full_route); + + // Find the Route + $route = $this->getFirstChunks($noFileExt); + + // Find the Method + $the_method = $this->getLastPart($noFileExt); + + $params = $query = $this->request->getUri()->getQuery(); + + // Now load Route + $is_from_the_controller = true; // TRUE for from Constructor... + return $this->router($route, $the_method, $params); + } + + private function getCtrlDir(): string + { + $ctrl = (Console::isConsole()) ? "cli_" : ""; + return ($this->testing) ? "test_" : $ctrl; + } + + /** + * Gets route and checks if valid + * @param string $route + * @param string $method + * @param bool $is_controller + * @retval action + */ + public function router(?string $route, ?string $method, $params) + { + $ROOT = IO_CORNERSTONE_PROJECT; + $file = ""; + $class = ""; + + if (empty(trim($route))) { + $uri = '/app/' . Configure::get('IOcornerstone', 'default_project'); + } else { + $uri = $route; + } + + try { + $filtered_uri = Security::filterUri($uri); + } catch (Exception $ex) { + return false; + } + + $safeFolders = Requires::filterDirPath( + $this->getFirstChunks($filtered_uri) + ); + if (Requires::isDangerous($safeFolders)) { + return false; + } + + $safeFolders = rtrim($safeFolders, '/'); + if (empty($ROOT)) { + return false; + } + + $safeFile = Requires::filterDirPath( + $this->getLastPart($filtered_uri) + ); + if (Requires::isDangerous($safeFile)) { + return false; + } + + $this->dirClass = $this->getCtrlDir(); + $dir = Requires::saferDirExists($ROOT . "{$this->dirClass}Controllers/" . $safeFolders); + +// it failed the _cli ctrl check so go back to default site controller + if ($dir === false) { + $dir = Requires::saferDirExists($ROOT . "Controllers/" . $safeFolders); + $this->dirClass = ""; // Reset to empty to use Normal CTRL + if ($dir === false) { + return false; + } + } + + $file = Requires::saferFileExists(basename($safeFile) . 'Controller.php', $dir); + if ($file === false || $file === null) { + return false; + } + $class = Security::filterClass($safeFolders) . "\\" . Security::filterClass($safeFile . "Controller"); + + if (empty(trim($method))) { + $method = ""; // Clear out null if exists + } + + if (substr($method, 0, 2) == '__') { + $method = ""; // Stop any magical methods being called + } + if ($method == "init") { + $method = ""; // Stop init methods from being called + } + + return $this->action($file, $class, $method, $params); + } + + private function local404(): ResponseInterface + { + return (new HttpFactory())->createResponse(404, [], '404 Page - Not Found'); + } + + /** + * Do controller action + * @param string $file + * @param string $class + * @param string $method + * @retval type + */ + private function action(string $file, string $class, string $method, $params) + { + $safer_file = Requires::saferFileExists($file); + if (!$safer_file) { + return false; + } + if (empty($class)) { + return false; + } + + $use_api = false; // Misc::isApi(); + + $callClass = "\\Project\\" . $this->dirClass . "Controllers\\" . $class; + $controller = new $callClass($this->request); + + // Collect controller-level middleware Directly from the controller file, IE: public static array $middleware = [ \Project\classes\auth_middleware::class ]; + $this->middleWare = $controller::$middleware ?? []; + + if ($method === "error" && str_contains($class, "app") && + method_exists($controller, $method) === false + ) { + //Broken_error(); + return false; + } + + if ($use_api) { + if (empty($method)) { + $method = "index"; + } + $method .= "_api"; + if (method_exists($controller, $method)) { + return $controller->$method($params); + } else { + return false; + } + } else { + if (!empty($method) && method_exists($controller, $method)) { + return $controller->$method($params); + } else { + if (empty($method) && method_exists($controller, 'index')) { + return $controller->index($params); + } + } + } + return false; + } +} + +// end of app diff --git a/src/Framework/CORSHandler.php b/src/Framework/CORSHandler.php new file mode 100644 index 0000000..1eeea00 --- /dev/null +++ b/src/Framework/CORSHandler.php @@ -0,0 +1,240 @@ +exactOrigins = $config['exact'] ?? []; + $this->wildcardPatterns = $config['wildcards'] ?? []; + $this->regexPatterns = $config['regex'] ?? []; + $this->allowSubdomains = $config['subdomains'] ?? []; + $this->blocked = $config['blocked'] ?? []; // Add your own blocklist + } + + /** + * @param string $extra = ", X-Requested-With, X-API-Key" + */ + public function doPreflight( + string $extra = "", + string $requestMethodDefault = "", // should be left empty; expect for testing purposes!! + string $originDefault = "" // should be left empty; expect for testing purposes!! + ) { + $requestMethod = $_SERVER['REQUEST_METHOD'] ?? $requestMethodDefault; // Web or CLI + if ($requestMethod === 'OPTIONS') { + $origin = $_SERVER['HTTP_ORIGIN'] ?? $originDefault; + if ($origin && $this->isOriginAllowed($origin)) { + $this->handlePreflight($extra); + } + exit(0); + } + } + + public function handle( + string $extra = "", + ?string $requestOrigin = null + ): bool { + $origin = $requestOrigin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''); + + if (!$origin || !$this->isValidOriginFormat($origin)) { + return false; + } + + if ($this->isOriginAllowed($origin)) { + $this->setCorsHeaders($origin); + return true; + } + + // Log unauthorized attempt (for monitoring) + $this->logUnauthorizedOrigin($origin); + return false; + } + + private function isOriginAllowed(string $origin): bool + { + if (empty($origin)) { + return false; + } + + // 1. Check exact matches + if (in_array($origin, $this->exactOrigins, true)) { + return true; + } + + // 2. Check wildcard patterns (e.g., "*.example.com") + foreach ($this->wildcardPatterns as $pattern) { + if ($this->matchesWildcard($origin, $pattern)) { + return true; + } + } + + // 3. Check regex patterns + foreach ($this->regexPatterns as $pattern) { + if (preg_match($pattern, $origin)) { + return true; + } + } + + // 4. Check subdomain patterns (e.g., allow all subdomains of example.com) + foreach ($this->allowSubdomains as $domain) { + if ($this->isSubdomainOf($origin, $domain)) { + return true; + } + } + + return false; + } + + private function matchesWildcard(string $origin, string $pattern): bool + { + // Convert wildcard pattern to regex + $regex = '/^' . str_replace( + ['\.', '\*', '\?'], + ['\.', '.*', '.'], + preg_quote($pattern, '/') + ) . '$/i'; + + return preg_match($regex, $origin) === 1; + } + + private function isSubdomainOf(string $origin, string $domain): bool + { + // Extract domain from origin (remove protocol) + $parsed = parse_url($origin); + if (!isset($parsed['host'])) { + return false; + } + + $host = $parsed['host']; + + // Check if it's exactly the domain + if ($host === $domain) { + return true; + } + + // Check if it's a subdomain of the given domain + return substr($host, -strlen($domain) - 1) === ".{$domain}"; + } + + private function isValidOriginFormat(string $origin): bool + { + // Basic origin format validation + if (!preg_match('/^https?:\/\/[a-zA-Z0-9\-\.]+(:[0-9]+)?$/', $origin)) { + return false; + } + + // Ensure it's not a loopback/localhost from external sources + $parsed = parse_url($origin); + if (!$parsed || !isset($parsed['host'])) { + return false; + } + + // Block common attack origins + $host = $parsed['host']; + if (in_array($host, $this->blocked)) { + return false; + } + + return true; + } + + private function setCorsHeaders($origin): void + { + header("Access-Control-Allow-Origin: {$origin}"); + header("Access-Control-Allow-Credentials: {$this->allowCredentials}"); + // header("Access-Control-Expose-Headers: X-Custom-Header"); + } + + private function logUnauthorizedOrigin(string $origin): void + { + // Log to file, monitoring service, or security system + error_log(sprintf( + 'Unauthorized CORS request from origin: %s at %s', + $origin, + date('Y-m-d H:i:s') + )); + } + + private function handlePreflight(?string $extra): void + { + $age = (string) $this->cacheAgeForAPI->value; + // Only set preflight headers if origin is allowed + header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); + header("Access-Control-Allow-Headers: Content-Type, Authorization {$extra}"); + header("Access-Control-Max-Age: {$age}"); + } +} + +/* + * Useage: + * +// Example 1: Multiple patterns for SaaS application +$corsConfig = [ + 'exact' => [ + 'https://app.company.com', + 'https://staging.company.com', + ], + 'wildcards' => [ + 'https://*.company.com', // All company subdomains + 'https://customer-*.company.com', // Customer-specific subdomains + ], + 'regex' => [ + '/^https:\/\/(dev|staging|test)-\d+\.company\.com$/i', // Dynamic environments + ], + 'subdomains' => [ + 'company.com', // Allow all HTTPS subdomains of company.com + ] +]; + +// Example 2: Multi-tenant SaaS with customer domains +$corsConfig = [ + 'exact' => [ + 'https://app.saasplatform.com', + 'https://admin.saasplatform.com', + ], + 'regex' => [ + '/^https:\/\/[a-z0-9-]+\.customerportal\.com$/i', // Customer portals + '/^https:\/\/(staging|dev)\.customer-[0-9]+\.saasplatform\.com$/i', + ] +]; + +// Example 3: Simple pattern for local development +$corsConfig = [ + 'exact' => [ + 'http://localhost:3000', + 'http://localhost:5173', + ], + 'wildcards' => [ + 'http://localhost:*', // All localhost ports + ] +]; + +// Initialize and handle CORS +$cors = new CORSHandler($corsConfig); +$cors->handle(); +*/ \ No newline at end of file diff --git a/src/Framework/CliDefaults.php b/src/Framework/CliDefaults.php new file mode 100644 index 0000000..de4188d --- /dev/null +++ b/src/Framework/CliDefaults.php @@ -0,0 +1,48 @@ +set(LoggerInterface::class, function () { + return new Logger( + filename: getenv('LOG_CHANNEL') ?: 'system', + maxCount: (int)(getenv('LOG_MAX_LINES') ?: 1000), + minLevel: getenv('LOG_LEVEL') ?: null + ); + }); + + /* Middleware binding */ + Reg::get('container')->set(RequestLoggerMiddleware::class, function ($c) { + return new RequestLoggerMiddleware( + $c->get(LoggerInterface::class) + ); + }); + + /* Error middleware config */ + Reg::get('container')->set(ErrorMiddleware::class, fn ($c) => + new ErrorMiddleware( + $c->get(LoggerInterface::class), + getenv('APP_ENV') !== 'production' + ) + ); + } +} \ No newline at end of file diff --git a/src/Framework/Common.php b/src/Framework/Common.php new file mode 100644 index 0000000..147997d --- /dev/null +++ b/src/Framework/Common.php @@ -0,0 +1,138 @@ +\r\nExpand to see Var Dump:"; + echo "

"; + var_dump($var); + echo "

"; + echo ""; + } else { + var_dump($var); + echo PHP_EOL; + } + + if ($var === false) { + echo 'It is FALSE!'; + } elseif ($var === true) { + echo 'It is TRUE!'; + } elseif (is_resource($var)) { + echo 'VAR IS a RESOURCE'; + } elseif (is_array($var) && self::get_count($var) == 0) { + echo 'VAR IS an EMPTY ARRAY!'; + } elseif (is_numeric($var)) { + echo 'VAR is a NUMBER = ' . $var; + } elseif (empty($var) && !is_null($var)) { + echo 'VAR IS EMPTY!'; + } elseif ($var == 'nothing') { + echo 'MISSING VAR!'; + } elseif (is_null($var)) { + echo 'VAR IS NULL!'; + } elseif (is_string($var)) { + if (! $isConsole) { + echo 'VAR is a STRING = ' . htmlentities($var); + } else { + echo 'VAR is a STRING = ' . $var; + } + } else { + if (! $isConsole) { + echo "
";
+                print_r($var);
+                echo '
'; + } else { + print_r($var); + echo PHP_EOL; + } + } + if (! $isConsole) { + echo '

'; + } + + if ($end === endDump::EXIT_AND_STOP) { + exit; + } + } + + /** + * Please note that not all web servers support: HTTP_X_REQUESTED_WITH + * So, you need to code more checks! + * @retval boolean true if AJAX request by JQuery, etc... + */ + public static function is_ajax(): bool { + $http_x_requested_with = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? ""; + return (strtolower($http_x_requested_with) === 'xmlhttprequest'); + } + + public static function nl2br(string $text): string + { + return strtr($text, array("\r\n" => '
', "\r" => '
', "\n" => '
')); + } +} \ No newline at end of file diff --git a/src/Framework/Configure.php b/src/Framework/Configure.php new file mode 100644 index 0000000..ef7e34c --- /dev/null +++ b/src/Framework/Configure.php @@ -0,0 +1,92 @@ + $value) { + self::$config[$name] = $value; + } + } + unset($a); + } + + private static function wipeData(&$sensitive_data): void + { + if (function_exists("sodium_memzero")) { + sodium_memzero($sensitive_data); + } + unset($sensitive_data); + } + +} diff --git a/src/Framework/Console.php b/src/Framework/Console.php new file mode 100644 index 0000000..9e82c25 --- /dev/null +++ b/src/Framework/Console.php @@ -0,0 +1,28 @@ +has($id)) { + return parent::get($id); + } + + if (!class_exists($id)) { + throw new \RuntimeException("Class {$id} not found"); + } + + return $this->autowire($id); + } + + private function autowire(string $class): object + { + $ref = new ReflectionClass($class); + $ctor = $ref->getConstructor(); + + if (!$ctor) { + return new $class(); + } + + $args = []; + + foreach ($ctor->getParameters() as $param) { + $type = $param->getType(); + + if (!$type || $type->isBuiltin()) { + throw new \RuntimeException( + "Cannot autowire {$class}::\${$param->getName()}" + ); + } + + $args[] = $this->get($type->getName()); + } + + return $ref->newInstanceArgs($args); + } +} diff --git a/src/Framework/Container/Container.php b/src/Framework/Container/Container.php new file mode 100644 index 0000000..0d75447 --- /dev/null +++ b/src/Framework/Container/Container.php @@ -0,0 +1,68 @@ +entries[$id] = $factory; + } + + public function get(string $id): mixed + { + if (!$this->has($id)) { + throw new class("No entry found for {$id}") + extends \RuntimeException + implements NotFoundExceptionInterface {}; + } + + try { + return $this->entries[$id]($this); + } catch (\Throwable $e) { + + $reset = (Console::isConsole()) ? "\033[0m" : ""; + + throw new class( + "Error while resolving {$id}" . $reset . PHP_EOL . + $this->dumpExceptionChain($e), + 0, + $e + ) extends \RuntimeException + implements ContainerExceptionInterface {}; + } finally { + // return ""; + } + } + + public function has(string $id): bool + { + return isset($this->entries[$id]); + } + + public function dumpExceptionChain(\Throwable $e): ?string + { + $level = 0; + $out = null; + while ($e !== null) { + $out .= "Level {$level}: " . get_class($e) . PHP_EOL; + $out .= $e->getMessage() . PHP_EOL; + $out .= $e->getFile() . ', on LINE #' . $e->getLine() . PHP_EOL; + // $out .= $e->getTraceAsString() . PHP_EOL; + $out .= str_repeat('-', 80) . PHP_EOL; + + $e = $e->getPrevious(); + $level++; + } + return $out; + } + +} diff --git a/src/Framework/DI.php b/src/Framework/DI.php new file mode 100644 index 0000000..5974828 --- /dev/null +++ b/src/Framework/DI.php @@ -0,0 +1,135 @@ +register(...$args); + } + + // alias to get_service + public function get(...$args) { + return $this->get_service(...$args); + } + + public function register(string $serviceName, callable $callable): void + { + $this->services[$serviceName] = $callable; + } + + public function has(string $serviceName): bool + { + return (array_key_exists($serviceName, $this->services)); + } + + public function exists(string $serviceName) + { // an Alias to has + return $this->has($serviceName); + } + + /* Note args may be an object or an array maybe more...! + * This will Call/Execute the service + */ + public function get_service( + string $serviceName, + $args = [], + ...$more + ) { + if ($this->has($serviceName) ) { + $entry = $this->services[$serviceName]; + + if (is_callable($entry)) { + return $entry($args, $more); + } + + $serviceName = $entry; + } + return $this->resolve(c); // Try to Auto-Wire + } + + public function get_auto(string $serviceName) + { + if ($this->has($serviceName) ) { + return $this->services[$serviceName]($this); + } + return $this->resolve($serviceName); // Try to Auto-Wire + } + + public function __set(string $serviceName, callable $callable): void + { + $this->register($serviceName, $callable); + } + + public function __get(string $serviceName) + { + return $this->get_service($serviceName); + } + + public function list_services_as_array(): array + { + return array_keys($this->services); + } + + public function list_services_as_string(): string + { + return implode(',', array_keys($this->services)); + } + // Reflection API Autowiring FROM-> https://www.youtube.com/watch?v=78Vpg97rQwE&list=PLr3d3QYzkw2xabQRUpcZ_IBk9W50M9pe-&index=72 + public function resolve(string $serviceName) + { + try { + $reflection_class = new \ReflectionClass($serviceName); + } catch (\ReflectionException $e) { + // if (! is_live()) { + // var_dump($e->getTrace()); + // echo $e->getMessage(); + // exit; + // } else { + throw new \Exception("Failed to resolve resource: {$serviceName}!"); + // } + } + if (! $reflection_class->isInstantiable()) { + throw new \Exception("The Service class: {$serviceName} is not instantiable."); + } + $constructor = $reflection_class->getConstructor(); + if (! $constructor) { + return new $serviceName; + } + $parameters = $constructor->getParameters(); + if (! $parameters) { + return new $serviceName; + } + $dependencies = array_map( + function(\ReflectionParameter $param) use ($serviceName) { + $name = $param->getName(); + $type = $param->getType(); + if (! $type) { + throw new \Exception("Failed to resolve class: {$serviceName} becasue param {$name} is missing a type hint."); + } + if ($type instanceof \ReflectionUnionType) { + throw new \Exception("Failed to resolve class: {$serviceName} because of union type for param {$name}."); + } + if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) { + return $this->get_auto($type->getName()); + } + + throw new \Exception("Failed to resolve class {$serviceName} because of invalid param {$param}."); + }, $parameters + ); + return $reflection_class->newInstanceArgs($dependencies); + } + +} \ No newline at end of file diff --git a/src/Framework/Enum/ExitOnDump.php b/src/Framework/Enum/ExitOnDump.php new file mode 100644 index 0000000..64ff54e --- /dev/null +++ b/src/Framework/Enum/ExitOnDump.php @@ -0,0 +1,15 @@ +debug) { + error_reporting(E_ALL); + } else { + // ini_set('display_errors', 0); + // ini_set('display_startup_errors', 0); + // // DISABLE XDEBUG DISPLAY FEATURES + // if (extension_loaded('xdebug')) { + // ini_set('xdebug.mode', 'off'); + // ini_set('xdebug.force_display_errors', 0); + // ini_set('xdebug.show_error_trace', 0); + // // Disable Xdebug temporarily + // if (function_exists('xdebug_disable')) { + // xdebug_disable(); + // } + // } + error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + } + + set_error_handler([$this, 'handleError']); + set_exception_handler([$this, 'handleException']); + register_shutdown_function([$this, 'handleShutdown']); + } + + public function unregister(): void + { + restore_error_handler(); + restore_exception_handler(); + } + + /** + * Convert PHP errors into ErrorException + */ + public function handleError( + int $severity, + string $message, + string $file, + int $line + ): bool { + $this->hasError = true; + + if (!(error_reporting() & $severity)) { + return false; + } + + throw new ErrorException($message, 0, $severity, $file, $line); + // Don't execute PHP's internal error handler + return true; + } + + /** + * Handle uncaught exceptions + */ + public function handleException($e): bool + { + $this->hasError = true; + + if (!$e instanceof Throwable) { + $e = new ErrorException('Unknown error'); + } + + if ($this->isJsonRequest()) { + if ($this->debug) { + $this->renderJsonDebug($e); + } else { + $this->renderJsonProduction($e); + } + return true; + } + + if (Console::isConsole()) { + if ($this->debug) { + $this->renderConsole($e); + } else { + $this->renderProductionConsole(); + $this->logException($e); + } + return true; + } + + http_response_code(500); + + if ($this->debug) { + $this->renderDebug($e); + } else { + $this->renderProduction(); + $this->logException($e); + } + // Don't execute PHP's internal error handler + return true; + } + + /** + * Handle fatal errors (E_ERROR, etc.) + */ + public function handleShutdown(): void + { + $error = error_get_last(); + + if ($error !== null && $this->isFatal($error['type'])) { + $exception = new ErrorException( + $error['message'], + 0, + $error['type'], + $error['file'], + $error['line'] + ); + + $this->handleException($exception); + } + + if ($this->hasError && Console::isConsole()) { + exit(1); + } + } + + private function isFatal(int $type): bool + { + return in_array($type, [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + ], true); + } + + private function getErrorType(Throwable $e): string + { + if ($e instanceof ErrorException) { + return match ($e->getSeverity()) { + E_ERROR => 'Fatal Error', + E_WARNING => 'Warning', + E_PARSE => 'Parse Error', + E_NOTICE => 'Notice', + E_CORE_ERROR => 'Core Error', + E_CORE_WARNING => 'Core Warning', + E_COMPILE_ERROR => 'Compile Error', + E_COMPILE_WARNING => 'Compile Warning', + E_USER_ERROR => 'User Error', + E_USER_WARNING => 'User Warning', + E_USER_NOTICE => 'User Notice', +// E_STRICT => 'Strict Standards', // Removed from 8.4+ + E_RECOVERABLE_ERROR => 'Recoverable Error', + E_DEPRECATED => 'Deprecated', + E_USER_DEPRECATED => 'User Deprecated', + default => 'Unknown PHP Error', + }; + } + + return match (true) { + $e instanceof \TypeError => 'Type Error', + $e instanceof \ParseError => 'Parse Error', + $e instanceof \Error => 'Fatal Error', + default => 'Exception', + }; + } + + private function isJsonRequest(): bool + { + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + + return str_contains($accept, 'application/json') + || str_contains($contentType, 'application/json'); + } + + private function buildTrace(Throwable $e): array + { + $trace = []; + + foreach ($e->getTrace() as $i => $frame) { + $trace[] = [ + 'index' => $i, + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'class' => $frame['class'] ?? null, + 'type' => $frame['type'] ?? null, + 'function' => $frame['function'] ?? null, + ]; + if ($i > 10) { + break; + } + } + + return $trace; + } + + private function setJsonHeaders(int $status_code = 500): void + { + if (!headers_sent()) { + /* + * Allow JavaScript from anywhere. CORS - Cross Origin Resource Sharing + * @link https://manning-content.s3.amazonaws.com/download/f/54fa960-332e-4a8c-8e7f-1eb213831e5a/CORS_ch01.pdf + */ + http_response_code($status_code); + header("Access-Control-Allow-Origin: *"); + header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); + header("Access-Control-Allow-Headers: Content-Type, Authorization"); + header('Content-Type: application/json; charset=utf-8'); + } + } + + private function renderJsonDebug(Throwable $e): void + { + $this->setJsonHeaders(); + + echo json_encode([ + 'error' => [ + 'type' => $this->getErrorType($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $this->buildTrace($e), + ] + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + private function renderJsonProduction(): void + { + $this->setJsonHeaders(); + + echo json_encode([ + 'error' => [ + 'message' => 'Internal Server Error' + ] + ]); + } + + private function colorFromType(Throwable $e): string + { + $gotType = $this->getErrorType($e); + if (str_contains($gotType, "Warning")) { + return "warning"; + } elseif (str_contains($gotType, "Notice")) { + return "notice"; + } + return "error"; + } + + private function renderConsole(Throwable $e): void + { + $colors = [ + 'error' => "\033[31m", // red + 'warning' => "\033[33m", // yellow + 'notice' => "\033[36m", // cyan + 'reset' => "\033[0m" // reset + ]; + $message = $e->getMessage(); + $type = $this->colorFromType($e); + $color = $colors[$type] ?? $colors['error']; + + $gotType = $this->getErrorType($e); + $out = "Uncaught " . $gotType . PHP_EOL; + $out .= wordwrap($message, WORD_WRAP_CHRS, "\n"); + $out .= $colors['reset']; + $out .= PHP_EOL. "In File: " . $e->getFile() . PHP_EOL; + $out .= "On Line # " . $e->getLine() . PHP_EOL; + $out .= $e->getTraceAsString() . PHP_EOL; + + echo $color . $out . PHP_EOL; + } + + private function formatWebMessage(Throwable $e): string + { + $styles = [ + 'error' => 'uk-alert-danger', + 'warning' => 'uk-alert-warning', + 'notice' => 'uk-alert-primary' + ]; + $type = $this->colorFromType($e); + $style = $styles[$type] ?? $styles['error']; + + $content = htmlspecialchars((string) $e); + + $message = wordwrap($content, WORD_WRAP_CHRS, "
\n"); + + $assets = "/assets/uikit/css/uikit.gradient.min.css"; + if (defined("BaseDir") && + file_exists(BaseDir. "/public/" . $assets) + ) { + $msg = ''; + $msg .= "
"; + } else { + $msg = "
"; + } + $msg .= $message; + $msg .= "
"; + return $msg; + } + + private function renderDebug(Throwable $e): void + { + echo "

Uncaught " . $this->getErrorType($e) . "

"; + echo $this->formatWebMessage($e); + } + + private function renderProductionConsole(): void + { + echo "An internal error occurred. Please try again later."; + } + + /** + * @todo Make error page red, etc... + */ + private function renderProduction(): void + { + echo "

An internal error occurred. Please try again later.

"; + } + + private function setLoggerByLevel(Throwable $e): void + { + $errorMessage = (string) $e; + $logger = new Logger(); + if ($logger === false) { + return; + } + if ($e instanceof ErrorException) { + switch($e->getSeverity()) { + case E_ERROR: + case E_CORE_ERROR: + case E_COMPILE_ERROR: + case E_USER_ERROR: + case E_RECOVERABLE_ERROR: + case E_PARSE: + $logger->error($errorMessage); + break; + case E_WARNING: + case E_CORE_WARNING: + case E_COMPILE_WARNING: + case E_USER_WARNING: + $logger->warning($errorMessage); + break; + case E_NOTICE: + case E_USER_NOTICE: + $logger->info($errorMessage); + break; + case E_DEPRECATED: + case E_USER_DEPRECATED: + $logger->debug($errorMessage); + break; + default: + $logger->error($errorMessage); + } + return; // Done + } + // Not ErrorException... + $logger->error($errorMessage); + } + + private function logException(Throwable $e): void + { + $this->setLoggerByLevel($e); + } +} diff --git a/src/Framework/Exception/BadMethodCallException.php b/src/Framework/Exception/BadMethodCallException.php new file mode 100644 index 0000000..7fb63fd --- /dev/null +++ b/src/Framework/Exception/BadMethodCallException.php @@ -0,0 +1,14 @@ +app->handle($request); + } +} diff --git a/src/Framework/Http/Contract/RouterInterface.php b/src/Framework/Http/Contract/RouterInterface.php new file mode 100644 index 0000000..55e2559 --- /dev/null +++ b/src/Framework/Http/Contract/RouterInterface.php @@ -0,0 +1,20 @@ +getAllHeaders(), + Stream::fromString(file_get_contents('php://input')), + $_SERVER, + queryParams: $_GET ?? [], + parsedBody: $_POST ?? null, + cookies: $_COOKIE ?? [], + uploadedFiles: $_FILES ?? [], + attributes: [], + protocol: $this->detectProtocol() + ); + } + + + public function createCliRequest(): ServerRequestInterface + { + global $argv; + + $method = $argv[1] ?? 'GET'; + $path = $argv[2] ?? $argv[1] ?? '/'; + + // Allow: php index.php /health + if ($path === $method) { + $method = 'GET'; + } + + return new Request( + $method, + Uri::fromString($path ?? '/'), + [ + 'REQUEST_URI' => $path, + 'REQUEST_METHOD' => $method, + ], + Stream::fromString(file_get_contents('php://input')), + $_SERVER, + queryParams: $_GET ?? [], + parsedBody: $_POST ?? null, + cookies: $_COOKIE ?? [], + uploadedFiles: $_FILES ?? [], + attributes: [], + protocol: $this->detectProtocol() + ); + } + + public function createResponse( + int $status = 200, + array $headers = [], + string $body = '' + ): ResponseInterface { + return new Response( + $status, + $headers, + Stream::fromString($body) + ); + } + + public function createStream(string $content = ''): StreamInterface + { + return Stream::fromString($content); + } + + private function detectProtocol(): string + { + if (!isset($_SERVER['SERVER_PROTOCOL'])) { + return '1.1'; + } + + return str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL']); + } + + /** + * Portable replacement for getallheaders() + */ + private function getAllHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + // Convert HTTP_HEADER_NAME to Header-Name + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); + $headers[$name] = [$value]; + } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) { + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $key)))); + $headers[$name] = [$value]; + } + } + return $headers; + } +} diff --git a/src/Framework/Http/Kernel.php b/src/Framework/Http/Kernel.php new file mode 100644 index 0000000..0d9f444 --- /dev/null +++ b/src/Framework/Http/Kernel.php @@ -0,0 +1,115 @@ +psrContainer = Reg::get('container'); + } + + private function registerRoutes(Router $router): void + { + if (! defined("IO_CORNERSTONE_PROJECT")) { + return; // Running CLI from Framework test script + } + if (! class_exists(\Project\Providers\RouteServiceProvider::class)) { + throw new NoRouteProviderFoundException("Class NOT found: Project\Providers\RouteServiceProvider"); + } + try { + $routeProviders = new \Project\Providers\RouteServiceProvider(); + } catch (\Throwable $e) { + throw new RouteProviderException("Unable to INIT RouteServiceProvider class \r\n" . $e->getMessage()); + } + if (! method_exists($routeProviders, "register")) { + throw new NoRouteProviderFoundException("Method NOT found: Project\Providers\RouteServiceProvider::register"); + } + try { + $routeProviders->register($router); + } catch (\Throwable $e) { + throw new RouteProviderException("Unable to call register on Route ServiceProvider \r\n" . $e->getMessage()); + } + } + + private function buildKernel() + { + $router = new Router(); + $this->registerRoutes($router); + + $fallback = new AppHandler( + $this->psrContainer->get(App::class) + ); + + return new MiddlewareQueueHandler( + [ + $this->psrContainer->get(ErrorMiddleware::class), + $this->psrContainer->get(RequestLoggerMiddleware::class), + ], + new RoutingHandler($router, $this->psrContainer, $fallback) + ); + } + + private function dispatch($kernel): Response + { + $httpFactory = new HttpFactory(); + if (Console::isConsole()) { + $createPsr7Request = $httpFactory->createCliRequest(); + } else { + $createPsr7Request = $httpFactory->createServerRequestFromGlobals(); + } + return $kernel->handle($createPsr7Request); + } + + private function emit(Response $response): void + { + http_response_code($response->getStatusCode()); + foreach ($response->getHeaders() as $name => $values) { + if (! is_array($values) && ! is_object($values)) { + continue; + } + foreach ($values as $value) { + header("$name: $value", false); + } + } + echo $response->getBody(); + } + + public function run(): void { + $kernel = $this->buildKernel(); + $response = $this->dispatch($kernel); + $this->emit($response); + } +} + +//dd($createPsr7Request->getQueryParams()['name']); +//dd($createPsr7Request->getVar()->get("name")); diff --git a/src/Framework/Http/MiddlewareQueueHandler.php b/src/Framework/Http/MiddlewareQueueHandler.php new file mode 100644 index 0000000..7178a39 --- /dev/null +++ b/src/Framework/Http/MiddlewareQueueHandler.php @@ -0,0 +1,29 @@ +middleware)) { + return $this->handler->handle($request); + } + + $middleware = array_shift($this->middleware); + + return $middleware->process($request, $this); + } +} \ No newline at end of file diff --git a/src/Framework/Http/Request.php b/src/Framework/Http/Request.php new file mode 100644 index 0000000..208fe6b --- /dev/null +++ b/src/Framework/Http/Request.php @@ -0,0 +1,62 @@ +getQueryParams()); + } + + public function postVar(): ParameterBag + { + return new ParameterBag((array) $this->getParsedBody()); + } + + public function cookiesVar(): ParameterBag + { + return new ParameterBag($this->getCookieParams()); + } + + /* ===================== + Framework helpers + ===================== */ + + public function isJson(): bool + { + return str_contains( + $this->getHeaderLine('Content-Type'), + 'application/json' + ); + } + +} diff --git a/src/Framework/Http/Response.php b/src/Framework/Http/Response.php new file mode 100644 index 0000000..6ebacae --- /dev/null +++ b/src/Framework/Http/Response.php @@ -0,0 +1,62 @@ +status; } + public function withStatus($code, $reasonPhrase = ''): self { + $clone = clone $this; + $clone->status = $code; + return $clone; + } + + public function getBody(): Stream { return $this->body; } + public function withBody(\Psr\Http\Message\StreamInterface $body): self { + $clone = clone $this; + $clone->body = $body; + return $clone; + } + + public function getHeaders(): array { return $this->headers; } + public function hasHeader($name): bool { return isset($this->headers[strtolower($name)]); } + public function getHeader($name): array { return $this->headers[strtolower($name)] ?? []; } + public function getHeaderLine($name): string { return implode(',', $this->getHeader($name)); } + + public function withHeader($name, $value): self { + $clone = clone $this; + $clone->headers[strtolower($name)] = (array)$value; + return $clone; + } + + public function withAddedHeader($name, $value): self { + $clone = clone $this; + $clone->headers[strtolower($name)][] = $value; + return $clone; + } + + public function withoutHeader($name): self { + $clone = clone $this; + unset($clone->headers[strtolower($name)]); + return $clone; + } + + public function getProtocolVersion(): string { return $this->protocol; } + public function withProtocolVersion($version): self { + $clone = clone $this; + $clone->protocol = $version; + return $clone; + } + + public function getReasonPhrase(): string { return ''; } +} diff --git a/src/Framework/Http/Routing/Router.php b/src/Framework/Http/Routing/Router.php new file mode 100644 index 0000000..beefd50 --- /dev/null +++ b/src/Framework/Http/Routing/Router.php @@ -0,0 +1,175 @@ + '\d+', + 'slug' => '[a-z0-9-]+', + 'alpha'=> '[a-zA-Z]+', + 'alnum'=> '[a-zA-Z0-9]+', + 'uuid' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}', + ]; + + // $router->addTypeAlias('year', '19\d{2}|20\d{2}'); + public function addTypeAlias(string $name, string $regex): void + { + $this->typeAliases[$name] = $regex; + } + + public function group(string $prefix, callable $callback, array $middleware = []): void + { + $this->groupStack[] = rtrim($prefix, '/'); + $this->groupMiddlewareStack[] = $middleware; + + $callback($this); + + array_pop($this->groupStack); + array_pop($this->groupMiddlewareStack); + } + + public function add( + string $method, + string $path, + mixed $handler, + array $middleware = [] + ): void + { + $prefix = implode('', $this->groupStack); + $path = $prefix . $path; + + [$regex, $params] = $this->compilePath($path); + + $this->routes[$method][] = [ + 'regex' => $regex, + 'params' => $params, + 'handler' => $handler, + 'middleware' => $middleware ?? [], + ]; + } + + public function dispatch(ServerRequestInterface $request): array + { + $method = $request->getMethod(); + $path = $request->getUri()->getPath(); + + foreach ($this->routes[$method] ?? [] as $route) { + if (!preg_match($route['regex'], $path, $matches)) { + continue; + } + + $params = []; + + foreach ($route['params'] as $name) { + $params[$name] = $matches[$name] ?? null; + } + + return [ + 'handler' => $route['handler'], + 'params' => $params, + 'middleware' => $this->resolveMiddleware($route), + ]; + } + + throw new RouteNotFoundException($method, $path); + } + + public function get(string $path, mixed $handler, array $middleware = []): void + { + $this->add('GET', $path, $handler, $middleware); + } + + public function post(string $path, mixed $handler, array $middleware = []): void + { + $this->add('POST', $path, $handler, $middleware); + } + + public function put(string $path, mixed $handler, array $middleware = []): void + { + $this->add('PUT', $path, $handler, $middleware); + } + + public function patch(string $path, mixed $handler, array $middleware = []): void + { + $this->add('PATCH', $path, $handler, $middleware); + } + + public function delete(string $path, mixed $handler, array $middleware = []): void + { + $this->add('DELETE', $path, $handler, $middleware); + } + + public function options(string $path, mixed $handler, array $middleware = []): void + { + $this->add('OPTIONS', $path, $handler, $middleware); + } + + private function compilePath(string $path): array + { + $params = []; + + $regex = preg_replace_callback( + '#\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\:([^}?]+))?(\?)?\}#', + function ($matches) use (&$params) { + $name = $matches[1]; + $type = $matches[2] ?? '[^/]+'; + $optional = isset($matches[3]); + + // Resolve aliases + if (isset($this->typeAliases[$type])) { + $type = $this->typeAliases[$type]; + } + + $params[] = $name; + + if ($optional) { + return '(?:/(?P<' . $name . '>' . $type . '))?'; + } + + return '(?P<' . $name . '>' . $type . ')'; + }, + $path + ); + + return ['#^' . $regex . '/?$#', $params]; + } + + private function resolveMiddleware(array $route): array + { + $middleware = []; + + // Group middleware (stacked) + foreach ($this->groupMiddlewareStack as $group) { + $middleware = array_merge($middleware, $group); + } + + // Route-level middleware + return array_merge( + $middleware, + $route['middleware'] ?? [] + ); + } + +} diff --git a/src/Framework/Http/Routing/RoutingHandler.php b/src/Framework/Http/Routing/RoutingHandler.php new file mode 100644 index 0000000..84c1051 --- /dev/null +++ b/src/Framework/Http/Routing/RoutingHandler.php @@ -0,0 +1,83 @@ +router->dispatch($request); + } catch (RouteNotFoundException $e) { + // Delegate to your existing controller logic + return $this->fallbackHandler->handle($request); + } + $request = $this->injectRouteAttributes($request, $route['params']); + + return $this->invokeHandler($route['handler'], $request); + } + + private function injectRouteAttributes( + ServerRequestInterface $request, + array $params + ): ServerRequestInterface { + foreach ($params as $key => $value) { + $request = $request->withAttribute($key, $value); + } + + return $request; + } + + private function invokeHandler( + mixed $handler, + ServerRequestInterface $request + ): ResponseInterface { + +// Callable (closure or function) + if (is_callable($handler)) { + return $handler($request); + } + +// [Controller::class, 'method'] + if (is_array($handler) && count($handler) === 2) { + [$class, $method] = $handler; + $obj = new $class($request); + return $obj->$method(); + } + +// Invokable controller + if (is_string($handler)) { + $controller = $this->container->get($handler); + + if (!method_exists($controller, '__invoke')) { + throw new \RuntimeException("Controller [$handler] is not invokable"); + } + + return $controller($request); + } + + throw new \RuntimeException('Invalid route handler'); + } + +} diff --git a/src/Framework/Http/Stream.php b/src/Framework/Http/Stream.php new file mode 100644 index 0000000..4c47c09 --- /dev/null +++ b/src/Framework/Http/Stream.php @@ -0,0 +1,53 @@ +resource = $resource; + } + + public static function fromString(string $content): self + { + $r = fopen('php://temp', 'r+'); + fwrite($r, $content); + rewind($r); + return new self($r); + } + + public function __toString(): string + { + if (!$this->resource) { + return ''; + } + + $pos = ftell($this->resource); + rewind($this->resource); + $contents = stream_get_contents($this->resource); + fseek($this->resource, $pos); + + return $contents ?: ''; + } + + public function close(): void { fclose($this->resource); } + public function detach() { $r = $this->resource; $this->resource = null; return $r; } + public function getSize(): ?int { return null; } + public function tell(): int { return ftell($this->resource); } + public function eof(): bool { return feof($this->resource); } + public function isSeekable(): bool { return true; } + public function seek($offset, $whence = SEEK_SET): void { fseek($this->resource, $offset, $whence); } + public function rewind(): void { rewind($this->resource); } + public function isWritable(): bool { return true; } + public function write($string): int { return fwrite($this->resource, $string); } + public function isReadable(): bool { return true; } + public function read($length): string { return fread($this->resource, $length); } + public function getContents(): string { return stream_get_contents($this->resource); } + public function getMetadata($key = null) { return null; } +} diff --git a/src/Framework/Http/Uri.php b/src/Framework/Http/Uri.php new file mode 100644 index 0000000..96fbc35 --- /dev/null +++ b/src/Framework/Http/Uri.php @@ -0,0 +1,78 @@ +scheme) { + $uri .= $this->scheme . '://'; + } + + if ($this->host) { + $uri .= $this->host; + } + + if ($this->port) { + $uri .= ':' . $this->port; + } + + $uri .= $this->path; + + if ($this->query) { + $uri .= '?' . $this->query; + } + + if ($this->fragment) { + $uri .= '#' . $this->fragment; + } + + return $uri; + } + + public function getScheme(): string { return $this->scheme; } + public function getAuthority(): string { return $this->host; } + public function getUserInfo(): string { return ''; } + public function getHost(): string { return $this->host; } + public function getPort(): ?int { return $this->port; } + public function getPath(): string { return $this->path; } + public function getQuery(): string { return $this->query; } + public function getFragment(): string { return $this->fragment; } + + public function withScheme($scheme): self { $c = clone $this; $c->scheme = $scheme; return $c; } + public function withUserInfo($user, $password = null): self { return $this; } + public function withHost($host): self { $c = clone $this; $c->host = $host; return $c; } + public function withPort($port): self { $c = clone $this; $c->port = $port; return $c; } + public function withPath($path): self { $c = clone $this; $c->path = $path; return $c; } + public function withQuery($query): self { $c = clone $this; $c->query = $query; return $c; } + public function withFragment($fragment): self { $c = clone $this; $c->fragment = $fragment; return $c; } +} diff --git a/src/Framework/LoadAll.php b/src/Framework/LoadAll.php new file mode 100644 index 0000000..4c11f4b --- /dev/null +++ b/src/Framework/LoadAll.php @@ -0,0 +1,51 @@ + $file) { + $file = trim($file); + if ($file === '..' || $file === '.') { + continue; + } + $on = substr($file, 0, 3); // grab first three letters + if ($on !== 'on_') { + continue; // It's not ON, so skip it! + } + $file_with_path = $path ."/" . $file; + Requires::secureInclude($file_with_path, UseDir::FIXED); + } + } +} \ No newline at end of file diff --git a/src/Framework/Logger.php b/src/Framework/Logger.php new file mode 100644 index 0000000..a52bb61 --- /dev/null +++ b/src/Framework/Logger.php @@ -0,0 +1,225 @@ + 600, + LogLevel::ALERT => 550, + LogLevel::CRITICAL => 500, + LogLevel::ERROR => 400, + LogLevel::WARNING => 300, + LogLevel::NOTICE => 250, + LogLevel::INFO => 200, + LogLevel::DEBUG => 100, + ]; + + private const ENV_LEVEL_MAP = [ + 'production' => LogLevel::ERROR, + 'prod' => LogLevel::ERROR, + + 'staging' => LogLevel::WARNING, + + 'testing' => LogLevel::NOTICE, + + 'development' => LogLevel::DEBUG, + 'dev' => LogLevel::DEBUG, + 'local' => LogLevel::DEBUG, + ]; + + public function __construct( + string $filename = 'system', + int $maxCount = 1000, + ?string $minLevel = null + ) { + $env = self::detectEnv(); + + if ($minLevel === null) { + $minLevel = self::ENV_LEVEL_MAP[$env] ?? LogLevel::ERROR; + } + + if (!isset(self::LEVEL_PRIORITY[$minLevel])) { + throw new InvalidArgumentException('Invalid log level: ' . $minLevel); + } + + $this->minLevel = $minLevel; + + // Stop Up Level attacks + if (str_contains($filename, '..')) { + return false; + } + + if (! defined("BaseDir")) { + return false; + } + + $logDir = Requires::filterDirPath(BaseDir . '/protected/logs'); + if (! Requires::isValidFile($logDir)) { + return false; + } + + if (!is_dir($logDir)) { + mkdir($logDir, 0775, true); + } + + if (! Requires::isValidFile($filename)) { + return false; + } + $safeFileName = Requires::filterFileName($filename); + if ($safeFileName === false || $safeFileName === "") { + return false; + } + $file = $logDir . '/' . $safeFileName . '.log.txt'; + + if ($maxCount > 1 && $this->getLines($file) > $maxCount) { + @unlink($file); + } + + if (! file_exists($file)) { + if (file_put_contents($file, "\n", FILE_APPEND) === false) { + return false; + } + } + @chmod($file, 0660); + @chgrp($file, 'www-data'); + + if (!is_writable($file)) { + return false; + } + + $this->handle = fopen($file, 'ab'); + return true; + } + + /* ---------------- PSR-3 Core ---------------- */ + + public function log($level, $message, array $context = []): void + { + if (!is_string($level) || !isset(self::LEVEL_PRIORITY[$level])) { + throw new InvalidArgumentException('Invalid log level'); + } + + if ( + self::LEVEL_PRIORITY[$level] + < self::LEVEL_PRIORITY[$this->minLevel] + ) { + return; + } + + if (!$this->handle || !is_resource($this->handle)) { + return; + } + + $message = $this->interpolate((string)$message, $context); + + $time = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + fwrite( + $this->handle, + sprintf("[%s] %s: %s\n", $time, strtoupper($level), $message) + ); + } + + /* ---------------- Level Methods ---------------- */ + + 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); + } + + /* ---------------- Helpers ---------------- */ + private static function detectEnv(): string + { + return strtolower( + getenv('APP_ENV') + ?: ($_ENV['APP_ENV'] ?? 'production') + ); + } + + private function interpolate(string $message, array $context): string + { + $replace = []; + + foreach ($context as $key => $value) { + if (is_scalar($value) || $value instanceof \Stringable) { + $replace['{' . $key . '}'] = (string)$value; + } + } + + return strtr($message, $replace); + } + + private function getLines(string $file): int + { + if (!file_exists($file)) { + return 0; + } + + $lines = 0; + $fh = fopen($file, 'rb'); + + if (!$fh) { + return 0; + } + + while (!feof($fh)) { + $lines += substr_count(fread($fh, 8192), "\n"); + } + + fclose($fh); + return $lines; + } + + public function __destruct() + { + if ($this->handle && is_resource($this->handle)) { + fclose($this->handle); + } + } +} diff --git a/src/Framework/Middleware/ErrorMiddleware.php b/src/Framework/Middleware/ErrorMiddleware.php new file mode 100644 index 0000000..374cce7 --- /dev/null +++ b/src/Framework/Middleware/ErrorMiddleware.php @@ -0,0 +1,53 @@ +handle($request); + } catch (\Throwable $e) { + $this->logger->error( + $e->getMessage(), + ['exception' => $e] + ); + + $bodyString = $this->displayErrors + ? $this->formatException($e) + : 'Internal Server Error'; + + $stream = Stream::fromString($bodyString); + + return new Response(500, ['Content-Type' => 'text/plain'], $stream); + } + } + + private function formatException(\Throwable $e): string + { + return sprintf( + "%s\n\n%s", + $e->getMessage(), + $e->getTraceAsString() + ); + } +} diff --git a/src/Framework/Middleware/RequestLoggerMiddleware.php b/src/Framework/Middleware/RequestLoggerMiddleware.php new file mode 100644 index 0000000..ab8f9f0 --- /dev/null +++ b/src/Framework/Middleware/RequestLoggerMiddleware.php @@ -0,0 +1,39 @@ +logger->info( + 'Incoming request {method} {uri}', + [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + ] + ); + + $response = $handler->handle($request); + + $this->logger->info( + 'Response {status}', + ['status' => $response->getStatusCode()] + ); + + return $response; + } +} diff --git a/src/Framework/ParameterBag.php b/src/Framework/ParameterBag.php new file mode 100644 index 0000000..23226ee --- /dev/null +++ b/src/Framework/ParameterBag.php @@ -0,0 +1,26 @@ +parameters[$key] ?? $default; + } + + public function has($key) { + return array_key_exists($key, $this->parameters); + } +} diff --git a/src/Framework/Playground/HttpContainer.php b/src/Framework/Playground/HttpContainer.php new file mode 100644 index 0000000..4ccd560 --- /dev/null +++ b/src/Framework/Playground/HttpContainer.php @@ -0,0 +1,109 @@ +bindings[$abstract] = [ + 'concrete' => $concrete, + 'shared' => $shared + ]; + } + + public function singleton(string $abstract, $concrete = null): void + { + $this->bind($abstract, $concrete, true); + } + + public function make(string $abstract, array $parameters = []) + { + // Return if already resolved as singleton + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + // Get the concrete implementation + $concrete = $this->bindings[$abstract]['concrete'] ?? $abstract; + + // If it's a closure, resolve it + if ($concrete instanceof \Closure) { + $object = $concrete($this, $parameters); + } elseif (is_string($concrete)) { + // If it's a class name, instantiate it + $object = $this->build($concrete, $parameters); + } else { // Otherwise use as-is + $object = $concrete; + } + + // Store if singleton + if (($this->bindings[$abstract]['shared'] ?? false) === true) { + $this->instances[$abstract] = $object; + } + + return $object; + } + + protected function build(string $class, array $parameters = []) + { + $reflector = new \ReflectionClass($class); + + // Check if class is instantiable + if (!$reflector->isInstantiable()) { + throw new \Exception("Class {$class} is not instantiable"); + } + + // Get the constructor + $constructor = $reflector->getConstructor(); + + // If no constructor, instantiate without arguments + if ($constructor === null) { + return new $class(); + } + + // Get constructor parameters + $dependencies = $constructor->getParameters(); + $instances = $this->resolveDependencies($dependencies, $parameters); + + return $reflector->newInstanceArgs($instances); + } + + protected function resolveDependencies(array $dependencies, array $parameters = []) + { + $results = []; + + foreach ($dependencies as $dependency) { + // Check if parameter was provided + if (array_key_exists($dependency->name, $parameters)) { + $results[] = $parameters[$dependency->name]; + continue; + } + + // Get the type hinted class + $type = $dependency->getType(); + + if ($type && !$type->isBuiltin()) { + $results[] = $this->make($type->getName()); + } elseif ($dependency->isDefaultValueAvailable()) { + $results[] = $dependency->getDefaultValue(); + } else { + throw new \Exception("Cannot resolve dependency {$dependency->name}"); + } + } + + return $results; + } +} \ No newline at end of file diff --git a/src/Framework/Playground/JunkForNow.php b/src/Framework/Playground/JunkForNow.php new file mode 100644 index 0000000..184f037 --- /dev/null +++ b/src/Framework/Playground/JunkForNow.php @@ -0,0 +1,79 @@ + '15', + ]; + + $filters = [ + 'component' => [ + 'filter' => FILTER_VALIDATE_INT, + 'flags' => FILTER_THROW_ON_FAILURE, + 'options' => [ + 'min_range' => 1, + 'max_range' => 10, + ], + ], + ]; + + filter_var_array($data, $filters); + + $login = "val1dL0gin"; + $filtered_login = filter_var($login, FILTER_CALLBACK, ['options' => 'validate_login']); + var_dump($filtered_login); + } + + /** + * @todo Remove from MISC.php + */ + public static function post_var( + string $var, + int $filter = FILTER_UNSAFE_RAW, + array|int $options = FILTER_NULL_ON_FAILURE + ): mixed { + return filter_input(INPUT_POST, $var, $filter, $options); + } + + public static function get_var( + string $var, + int $filter = FILTER_UNSAFE_RAW, + array|int $options = FILTER_NULL_ON_FAILURE + ): mixed { + return filter_input(INPUT_GET, $var, $filter, $options); + } + + public static function request_var( + string $var, + int $filter = FILTER_UNSAFE_RAW, + array|int $options = FILTER_NULL_ON_FAILURE + ): mixed { + if (filter_has_var(INPUT_POST, $var)) { + return self::post_var($var, $filter, $options); + } + if (filter_has_var(INPUT_GET, $var)) { + return self::get_var($var, $filter, $options); + } + return ""; + } + + function validate_login(string $value): ?string { + if (strlen($value) >= 5 && ctype_alnum($value)) { + return $value; + } + return null; + } +} diff --git a/src/Framework/Playground/RouteServiceProvider.php b/src/Framework/Playground/RouteServiceProvider.php new file mode 100644 index 0000000..1ffa46b --- /dev/null +++ b/src/Framework/Playground/RouteServiceProvider.php @@ -0,0 +1,77 @@ +getContent(); + } catch (\Throwable $e) { + throw new \Exception("No Response from [Controller]?"); + } + if (! empty($data)) { + $response->setContent($data); + } + + return $response; + } + ); + return $middleware_stack($request, $response); + } + + public function register(): void + { + // Add router middleware + $this->kernel->addMiddleware(function (Request $request, Response $response, $next) { + $returned_route = Router::execute($request, $response); + if ($returned_route["found"] === false) { + $app = new App($request, $response); + $returned = $app->loadController(); + $a_middleware = $returned['middleware'] ?? []; + $data = $returned['data'] ?? ""; + return $this->build($data, $response, $request, $next, $a_middleware); + } else { + return $this->build($returned_route['returned'], $response, $request, $next, $returned_route['middleware']); + } + }); + } +} diff --git a/src/Framework/Playground/Router.php b/src/Framework/Playground/Router.php new file mode 100644 index 0000000..c56faa5 --- /dev/null +++ b/src/Framework/Playground/Router.php @@ -0,0 +1,492 @@ + '(\d+)', // Any Number + 's' => '(\w+)', // Any Word + 'locale' => '(sk|en)->en' + ]; + + /** + * Init new self instance + */ + public static function init() { + self::$instance = new self(); + } + + /** + * Assign name to route + * + * @param $name + */ + public static function name($name) + { + $route = self::$routes[self::$last]; + unset(self::$routes[self::$last]); + self::$routes[self::$name . $name] = $route; + } + + /** + * Add custom shortcut + * shortcut with default value -> $regex = (val1|val2)->val1; + * + * @param $shortcut + * @param $regex + */ + public static function addShortcut($shortcut, $regex) + { + self::$shortcuts[$shortcut] = $regex; + } + + /** + * Get link from route name and params + * + * @param $route + * @param array $params + * @param $absolute + * @return null + */ + public static function link($route, $params = [], $absolute = true) + { + if (!isset(self::$routes[$route])) return null; + $route = self::$routes[$route]; + + $link = ""; + foreach ($route['params'] as $key => $param) { + if (isset($param['real'])) { + $link .= $param['pattern'] . '/'; + } else if (isset($params[$param['name']])) { + // Chcek if param has default + if (isset($param['default']) && $param['default'] !== $params[$param['name']]) { + $link .= $params[$param['name']] . '/'; + } + } + } + + // Add absolute path + if ($absolute) { + $link = self::$URL . $link; + } + + // Cut slash at the end + return rtrim($link, '/'); + } + + /** + * Redirect to specific route or defined location + * + * @param $route + * @param array $params + */ + public static function redirect($route, $params = []) + { + if (isset(self::$routes[$route])) { + $route = self::link($route, $params); + } + header('Location: ' . $route, true); + die(); + } + + /** + * Create prefixed routes + * + * @param $prefix + * @param $callback + * @param string $name + * @param array $doNotIncludeInParams + */ + public static function prefix($prefix, $callBack, $name = '', $doNotIncludeInParams = []) + { + self::$prefix = $prefix; + self::$name = $name; + self::$doNotIncludeInParams = $doNotIncludeInParams; + call_user_func($callBack); + self::$prefix = null; + self::$name = null; + self::$doNotIncludeInParams = []; + } + + /** + * Create route + * + * @param $route + * @param $action + * @param array $method + * @return Router + */ + public static function route($route, $action, $method = ["POST", "GET"]) + { + $prefix = self::$prefix; + if (empty($route) && !empty(self::$prefix)) { + $prefix = rtrim(self::$prefix, '/'); + } + + + $explodedRoute = explode("/", $prefix . $route); + $params = []; + $pattern = ""; + + // create route + foreach ($explodedRoute as $key => $r) { + if (strpos($r, '}?') !== false) { + $r = self::dynamic($r, $params, true); + $pattern = substr($pattern, 0, -1); + $dyn = true; + } else if (strpos($r, '}') !== false) { + $r = self::dynamic($r, $params, false); + $pattern = substr($pattern, 0, -1); + $dyn = true; + } else { + $params[] = [ + 'pattern' => $r, + 'real' => false + ]; + } + $pattern .= ($key == 0 && !isset($dyn) ? '/' : '') . $r . '/'; + } + + // Create pattern + $pattern = substr($pattern, 0, -1) . '/?'; + $pattern = '~^' . str_replace('/', '\/', $pattern) . '$~'; + + // Save data to static property + $name = uniqid(); + self::$routes[$name] = [ + 'route' => $route, + 'pattern' => $pattern, + 'params' => $params, + 'action' => $action, + 'method' => $method, + 'doNotIncludeInParams' => self::$doNotIncludeInParams + ]; + // Set last added + self::$last = $name; + + // return self instance + if (self::$instance == null) { + self::init(); + } + return self::$instance; + } + + public static function get($route, $action) { + return self::route($route, $action, ['GET']); + } + + public static function post($route, $action) { + return self::route($route, $action, ['POST']); + } + + public static function put($route, $action) { + return self::route($route, $action, ['PUT']); + } + + public static function delete($route, $action) { + return self::route($route, $action, ['DELETE']); + } + + public static function any($route, $action) { + return self::route($route, $action, ['GET', 'POST', 'PUT', 'DELETE']); + } + + /** + * Create all routes for resource (CRUD) + * + * @param $route + * @param $controller + * @param string $name + * @param string $shortcut + */ + public static function resource($route, $controller, $name = null, $shortcut = 's') + { + self::$name = self::$name . $name; + + self::get($route . '/{page::paginator}?', $controller . "@index")->name('index'); + self::get($route . "/create", $controller . "@create")->name('create'); + self::post($route, $controller . "@store")->name('store'); + self::get($route . "/{id::" . $shortcut . "}", $controller . "@show")->name('show'); + self::get($route . "/{id::" . $shortcut . "}/edit", $controller . "@edit")->name('edit'); + self::put($route . "/{id::" . $shortcut . "}", $controller . "@update")->name('update'); + self::delete($route . "/{id::" . $shortcut . "}", $controller . "@destroy")->name('destroy'); + self::get($route . "/{id::" . $shortcut . "}/delete", $controller . "@destroy")->name('destroy_'); + + self::$name = str_replace($name, '', self::$name); + } + + + /** + * Handle dynamic parameter in route + * + * @param string $route + * @param array $params + * @param boolean $optional + * @return mixed + */ + private static function dynamic($route, &$params, $optional = false) + { + $shortcut = self::rules($route); + + $name = str_replace('{', '', $route); + if (!$optional) { + $name = str_replace('}', '', $name); + } else { + $name = str_replace('}?', '', $name); + } + + if (strpos($name, '::')) { + $name = substr($name, 0, strpos($name, "::")); + } + + if (array_search($name, array_column($params, 'name')) === false) { + + $pattern = str_replace('(', '(/', $shortcut['shortcut']); + $pattern = str_replace('|', '|/', $pattern); + + // If is optional add ? at the end of pattern + if ($optional) { + $pattern .= '?'; + } + + $params[] = [ + 'name' => $name, + 'pattern' => $pattern, + 'default' => $shortcut['default'] + ]; + } else { + throw new \Exception('Parameter with name ' . $name . ' has been already defined'); + } + return $pattern; + } + + /** + * Dynamic route rules + * + * @param $route + * @return mixed + */ + private static function rules($route) + { + if (preg_match('~::(.*?)}~', $route, $match)) { + list(, $shortcut) = $match; + if (isset(self::$shortcuts[$shortcut])) { + // Try to get default value from shortcut + $default = false; + $shortcut = self::$shortcuts[$shortcut]; + if (preg_match('~->(.*?)$~', $shortcut, $match)) { + $default = $match[1]; + $shortcut = str_replace($match[0], '', $shortcut); + } + // Return shortcut and default value + return [ + 'shortcut' => $shortcut, + 'default' => $default + ]; + } + } + return [ + 'shortcut' => self::$shortcuts['s'], + 'default' => false + ]; + } + + /** + * get_all_routes -> Added by Robert Strutts to auto load routes + * Namespace must start with Project + * Needs a routes folder, then + * either an routes.php or test_routes.php files must exists + * with a method called get! + */ + private static function get_all_routes(bool $testing = false) { + $route_name = (console_app::is_cli()) ? "cli_routes" : "routes"; + $routes = ($testing) ? "test_routes" : $route_name; + $routes_class = "\\Project\\routes\\{$routes}"; + + if (class_exists($routes_class)) { + if (method_exists($routes_class, "get")) { + $callback = "{$routes_class}::get"; + call_user_func($callback); + } + } + } + + /** + * Execute router + */ + public static function execute(Request $my_request, Response $my_response) { + $ROOT = bootstrap\site_helper::get_root(); + $request_uri = bootstrap\site_helper::get_uri(); + $request_method = bootstrap\site_helper::get_method(); + $testing = bootstrap\site_helper::get_testing(); + + // Generate request and absolute path + self::generateURL($ROOT, $request_uri); + // Fecth all Routes from the Project + self::get_all_routes($testing); + + // Get query string + $request = explode('?', $request_uri); + $queryParams = []; + if (isset($request[1])) { + $queryParams = $request[1]; + parse_str($queryParams, $queryParams); + self::$queryParams = $queryParams; + } + + // Modify request + $request = '/' . trim(self::$REQUEST, '/'); + + // Find route + foreach (self::$routes as $routeKey => $route) { + $post_method = misc::post_var("_method"); + $matchMethod = in_array($request_method, $route['method']) || ($post_method !== null + && in_array($post_method, $route['method'])); + if (preg_match($route['pattern'], $request, $match) && $matchMethod) { + + // Default variables + $explodedRequest = explode('/', ltrim($request, '/')); + $routeParams = $route['params']; + + // Match request params with params in array - static params + foreach ($explodedRequest as $key => $value) { + foreach ($routeParams as $k => $routeParam) { + // Go to the next request part + if (isset($routeParam['real']) && $routeParam['pattern'] == $value) { + unset($routeParams[$k]); + unset($explodedRequest[$key]); + break; + } + } + } + + $number = "(/\d+)"; + $params = []; + // Match request params with params in array - dynamic params + foreach ($routeParams as $k => $routeParam) { + $value = $explodedRequest[$k] ?? false; + $default = $routeParam['default'] ?? true; + $pattern = $routeParam['pattern'] ?? ""; + $wild = str_contains($pattern, "?"); + if (! $wild && ($value === false && $default)) { + $params[$routeParam['name']] = null; + } else if ($wild && ($value === false && ! $default)) { + // Let the default function params be used by not setting anything here! + } else if (preg_match('~' . $pattern . '~', '/' . $value, $match)) { + if (str_contains($pattern, $number)) { + $params[$routeParam['name']] = (int) $value; + } else { + $params[$routeParam['name']] = $value; + } + unset($routeParams[$k]); + } + } + + // Merge with query params + self::$params = array_merge($params, $queryParams); + + // Setup default route and url + self::$route = $route; + + foreach ($params as $key => $value) { + if (in_array($key, $route['doNotIncludeInParams']) && !is_numeric($key)) { + unset($params[$key]); + } + } + + // Call action + if (is_callable($route['action'])) { + $returned = call_user_func_array($route['action'], $params); + return ["found"=> true, "returned"=> $returned]; + } else if (strpos($route['action'], '@') !== false) { + + // call controller + list($controller, $method) = explode('@', $route['action']); + + // init new controller + $controller = new $controller($my_request, $my_response); + + // Collect controller-level middleware + $controller_middleware = $controller::$middleware ?? []; + + // Check if class has parent + $parentControllers = class_parents($controller); + if (!empty($parentControllers)) { + end($parentControllers); + $parentController = $parentControllers[key($parentControllers)]; + $parentController = new $parentController($my_request, $my_response); + + // Add properties to parent class + foreach ($params as $key => $value) { + $parentController::$params[$key] = $value; + } + } + + //Call method + if (method_exists($controller, $method)) { + $returned = call_user_func_array([$controller, $method], $params); + return ["found"=> true, "returned"=> $returned, "middleware"=>$controller_middleware]; + } + } + } + } + return ["found"=>false]; + } + + /** + * Generate URL + * + * @param $ROOT + */ + private static function generateURL(string $ROOT, string $request_uri) + { + $https = bootstrap\safer_io::get_clean_server_var('HTTPS'); + $baseLink = ($https === 'on') ? "https" : "http"; + + $server_name = bootstrap\safer_io::get_clean_server_var('SERVER_NAME'); + $baseLink .= "://" . $server_name; + + $port = bootstrap\safer_io::get_clean_server_var('SERVER_PORT'); + $baseLink .= ($port !== '80') ? ':' . $port : ':'; + + $baseRequest = ''; + + $request = $request_uri; + foreach (explode('/', $ROOT) as $key => $value) { + if ($value == '') continue; + if (preg_match('~/' . $value . '~', $request)) { + $baseRequest .= $value . '/'; + } + $request = preg_replace('~/' . $value . '~', '', $request, 1); + } + + self::$URL = $baseLink . '/' . $baseRequest; + self::$REQUEST = explode('?', $request)[0]; + } + +} \ No newline at end of file diff --git a/src/Framework/Playground/ServiceProvider.php b/src/Framework/Playground/ServiceProvider.php new file mode 100644 index 0000000..2bc0de5 --- /dev/null +++ b/src/Framework/Playground/ServiceProvider.php @@ -0,0 +1,46 @@ +kernel = $kernel; + } + + abstract public function register(): void; +} + +/** + * Example Useage: +namespace App\Providers; + +use IOcornerstone\Framework\Http\kernel; +use IOcornerstone\Framework\Http\service_provider; +use App\Services\Database; + +class app_service_provider extends service_provider +{ + public function register(): void + { + $this->kernel->getContainer()->singleton(Database::class, function() { + return new Database( + getenv('DB_HOST'), + getenv('DB_USER'), + getenv('DB_PASS'), + getenv('DB_NAME') + ); + }); + } +} + */ \ No newline at end of file diff --git a/src/Framework/Playground/pipeOp.php b/src/Framework/Playground/pipeOp.php new file mode 100644 index 0000000..485d6aa --- /dev/null +++ b/src/Framework/Playground/pipeOp.php @@ -0,0 +1,32 @@ +) + * to chain multiple callables from left to right, + * taking the return value of the left callable and + * passing it to the right. + * + * They are suitable when all of the functions in the + * chain require only one parameter, have return values, + * and do not accept by-reference parameters. + + +echo trim("Hello, World!") + |> strtoupper(...) + |> str_shuffle(...) + |> str_rot13(...) + |> yonks(...); + * + */ \ No newline at end of file diff --git a/src/Framework/Registry.php b/src/Framework/Registry.php new file mode 100644 index 0000000..80134ed --- /dev/null +++ b/src/Framework/Registry.php @@ -0,0 +1,36 @@ + ".twig", + ".tpl" => ".tpl", + default => ".php", + }; + } + + $filteredFile = self::filterFileName($fileName); + if (empty($dir)) { + $filePlusDir = $filteredFile; + } else { + $filteredDir = rtrim(self::filterDirPath($dir), '/') . '/'; + $filePlusDir = $filteredDir . $filteredFile; + } + $escapedFile = escapeshellcmd($filePlusDir . $fileType); + return self::getPHPVersionForFile($escapedFile); + } + + private static function obStarter() { + if (extension_loaded('mbstring')) { + ob_start('mb_output_handler'); + } else { + ob_start(); + } + } + + private static function secureFile(bool $returnContents, string $file, UseDir $path, $local = null, array $args = array(), bool $loadOnce = true) { + $dir = match ($path) { + UseDir::FIXED => "", + UseDir::FRAMEWORK => IO_CONERSTONE_FRAMEWORK, + UseDir::ONERROR => IO_CONERSTONE_PROJECT . "views/on_error/", + default => IO_CONERSTONE_PROJECT, + }; + $versionedFile = self::saferFileExists($file, $dir); + if ($versionedFile === false) { + return false; + } + + if (is_array($args)) { + if (isset($args["return_contents"]) + || isset($args["script_output"]) + || isset($args["versioned_file"]) + ) { + return false; // Args are Dangerious!! Abort + } + extract($args, EXTR_PREFIX_SAME, "dup"); + } + + if (defined('CountFiles') && CountFiles) { + self::$loadedFiles[] = $versionedFile; + } + if ($returnContents) { + $scriptOutput = (string) ob_get_clean(); + self::obStarter(); + include $versionedFile; + $scriptOutput .= (string) ob_get_clean(); + return $scriptOutput; + } else { + return ($loadOnce) ? include_once($versionedFile) : include($versionedFile); + } + } + + /** + * Use secure_include instead of include. + * @param string $file script + * @param UseDir $path (framework or project) + * @param type $local ($this) + * @param array $args ($var to pass along) + * @param bool $load_once (default true) + * @return results of include or false if failed + */ + public static function secureInclude(string $file, UseDir $path = UseDir::PROJECT, $local = null, array $args = array(), bool $loadOnce = true) { + return self::secureFile(false, $file, $path, $local, $args, $loadOnce); + } + + public static function secureGetContent(string $file, UseDir $path = UseDir::PROJECT, $local = null, array $args = array(), bool $loadOnce = true) { + return self::secureFile(true, $file, $path, $local, $args, $loadOnce); + } + +} diff --git a/src/Framework/Security.php b/src/Framework/Security.php new file mode 100644 index 0000000..1b434b4 --- /dev/null +++ b/src/Framework/Security.php @@ -0,0 +1,312 @@ +"; + } + + /** + * Check if POST data CSRF Token is Valid + * @return bool is valid + */ + public static function csrfTokenIsValid(int $filter = FILTER_UNSAFE_RAW): bool { + $isCsrf = filter_has_var(INPUT_POST, 'csrf_token'); + if ($isCsrf) { + $userToken = filter_input(INPUT_POST, 'csrf_token', $filter); + $storedToken = $_SESSION['csrf_token'] ?? ''; + if (empty($storedToken)) { + return false; + } + return ($user_token === $stored_token); + } else { + return false; + } + } + + /** + * Optional check to see if token is also recent + * @return bool + */ + public static function csrfTokenIsRecent(): bool { + $max_elapsed = intval(Configure::get( + 'security', + 'max_token_age' + )); + if ($max_elapsed < 30) { + $max_elapsed = 60 * 60 * 24; // 1 day + } + + if (isset($_SESSION['csrf_token_time'])) { + $stored_time = $_SESSION['csrf_token_time']; + return ($stored_time + $max_elapsed) >= time(); + } else { + // Remove expired token + self::destroyCsrfToken(); + return false; + } + } +} diff --git a/src/Framework/Trait/Security/SessionHijackingFunctions.php b/src/Framework/Trait/Security/SessionHijackingFunctions.php new file mode 100644 index 0000000..8e482c1 --- /dev/null +++ b/src/Framework/Trait/Security/SessionHijackingFunctions.php @@ -0,0 +1,161 @@ +has('sessions')) { + Registry::get('di')->get_service('sessions'); + } + } + +// Function to forcibly end the session + public static function endSession() { + // Use both for compatibility with all browsers + // and all versions of PHP. + session_unset(); + session_destroy(); + } + +// Does the request IP match the stored value? + public static function requestIpMatchesSession() { + // return false if either value is not set + if (!isset($_SESSION['ip']) || !isset($_SERVER['REMOTE_ADDR'])) { + return false; + } + if ($_SESSION['ip'] === $_SERVER['REMOTE_ADDR']) { + return true; + } else { + return false; + } + } + +// Does the request user agent match the stored value? + public static function requestUserAgentMatchesSession() { + // return false if either value is not set + if (!isset($_SESSION['user_agent']) || !isset($_SERVER['HTTP_USER_AGENT'])) { + return false; + } + if ($_SESSION['user_agent'] === $_SERVER['HTTP_USER_AGENT']) { + return true; + } else { + return false; + } + } + +// Has too much time passed since the last login? + public static function lastLoginIsRecent() { + $max_elapsed = intval(Configure::get( + 'security', + 'max_last_login_age' + )); + if ($max_elapsed < 30) { + $max_elapsed = 60 * 60 * 24; // 1 day + } + + // return false if value is not set + if (!isset($_SESSION['last_login'])) { + return false; + } + if (($_SESSION['last_login'] + $max_elapsed) >= time()) { + return true; + } else { + return false; + } + } + +// Should the session be considered valid? + public static function isSessionValid() { + $check_ip = true; + $check_user_agent = true; + $check_last_login = true; + + if ($check_ip && !self::requestIpMatchesSession()) { + return false; + } + if ($check_user_agent && !self::requestUserAgentMatchesSession()) { + return false; + } + if ($check_last_login && !self::lastLoginIsRecent()) { + return false; + } + return true; + } + +// If session is not valid, end and redirect to login page. + public static function confirmSessionIsValid( + string $login = "login.php" + ) { + if (!self::isSessionValid()) { + self::endSession(); + // Note that header redirection requires output buffering + // to be turned on or requires nothing has been output + // (not even whitespace). + header("Location: " . $login ); + exit; + } + } + +// Is user logged in already? + public static function isLoggedIn() { + return (isset($_SESSION['logged_in']) && $_SESSION['logged_in']); + } + +// If user is not logged in, end and redirect to login page. + public static function confirmUserLoggedIn( + string $login = "login.php" + ) { + if (!self::isLoggedIn()) { + self::endSession(); + // Note that header redirection requires output buffering + // to be turned on or requires nothing has been output + // (not even whitespace). + header("Location: " . $login); + exit; + } + } + +// Actions to preform after every successful login + public static function afterSuccessfulLogin() { + // Regenerate session ID to invalidate the old one. + // Super important to prevent session hijacking/fixation. + session_regenerate_id(); + + $_SESSION['logged_in'] = true; + + // Save these values in the session, even when checks aren't enabled + $_SESSION['ip'] = $_SERVER['REMOTE_ADDR']; + $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT']; + $_SESSION['last_login'] = time(); + } + +// Actions to preform after every successful logout + public static function afterSuccessfulLogout() { + $_SESSION['logged_in'] = false; + self::endSession(); + } + +// Actions to preform before giving access to any +// access-restricted page. + public static function beforeEveryProtectedPage( + string $login = "login.php" + ) { + self::confirmUserLoggedIn($login); + self::confirmSessionIsValid(); + } +} diff --git a/src/Framework/Trait/ServerRequestDelegation.php b/src/Framework/Trait/ServerRequestDelegation.php new file mode 100644 index 0000000..61643b0 --- /dev/null +++ b/src/Framework/Trait/ServerRequestDelegation.php @@ -0,0 +1,208 @@ +requestTarget !== '') { + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + $query = $this->uri->getQuery(); + + return $query ? $target . '?' . $query : $target; + } + + public function withRequestTarget($requestTarget): self + { + $clone = clone $this; + $clone->requestTarget = $requestTarget; + return $clone; + } + + public function getMethod(): string + { + return $this->method; + } + + public function withMethod($method): self + { + $clone = clone $this; + $clone->method = $method; + return $clone; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false): self + { + $clone = clone $this; + $clone->uri = $uri; + return $clone; + } + + /* ---------- MessageInterface ---------- */ + + public function getProtocolVersion(): string + { + return $this->protocol; + } + + public function withProtocolVersion($version): self + { + $clone = clone $this; + $clone->protocol = $version; + return $clone; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader($name): bool + { + return isset($this->headers[strtolower($name)]); + } + + public function getHeader($name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + public function getHeaderLine($name): string + { + return implode(',', $this->getHeader($name)); + } + + public function withHeader($name, $value): self + { + $clone = clone $this; + $clone->headers[strtolower($name)] = (array) $value; + return $clone; + } + + public function withAddedHeader($name, $value): self + { + $clone = clone $this; + $clone->headers[strtolower($name)][] = $value; + return $clone; + } + + public function withoutHeader($name): self + { + $clone = clone $this; + unset($clone->headers[strtolower($name)]); + return $clone; + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function withBody(StreamInterface $body): self + { + $clone = clone $this; + $clone->body = $body; + return $clone; + } + + /* ---------- ServerRequestInterface ---------- */ + + public function getServerParams(): array + { + return $this->serverParams; + } + + public function getCookieParams(): array + { + return $this->cookies; + } + + public function withCookieParams(array $cookies): self + { + $clone = clone $this; + $clone->cookies = $cookies; + return $clone; + } + + public function getQueryParams(): array + { + return $this->queryParams; + } + + public function withQueryParams(array $query): self + { + $clone = clone $this; + $clone->queryParams = $query; + return $clone; + } + + public function getParsedBody(): mixed + { + return $this->parsedBody; + } + + public function withParsedBody($data): self + { + $clone = clone $this; + $clone->parsedBody = $data; + return $clone; + } + + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + public function withUploadedFiles(array $uploadedFiles): self + { + $clone = clone $this; + $clone->uploadedFiles = $uploadedFiles; + return $clone; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute($name, $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + public function withAttribute($name, $value): self + { + $clone = clone $this; + $clone->attributes[$name] = $value; + return $clone; + } + + public function withoutAttribute($name): self + { + $clone = clone $this; + unset($clone->attributes[$name]); + return $clone; + } + +} \ No newline at end of file diff --git a/src/LICENSE b/src/LICENSE new file mode 100644 index 0000000..c2783a8 --- /dev/null +++ b/src/LICENSE @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2010-2026 Robert Strutts + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/Psr4AutoloaderClass.php b/src/Psr4AutoloaderClass.php new file mode 100644 index 0000000..fbc2a30 --- /dev/null +++ b/src/Psr4AutoloaderClass.php @@ -0,0 +1,180 @@ + + * @copyright Copyright (c) 2013-2017 PHP Framework Interop Group + * @license MIT + * @site https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader-examples.md + */ +final class Psr4AutoloaderClass { + + /** + * An associative array where the key is a namespace prefix and the value + * is an array of base directories for classes in that namespace. + * + * @var array + */ + protected $prefixes = []; + protected $loadedFiles = []; + + /** + * Register loader with SPL autoloader stack. + * + * @return void + */ + public function register() { + spl_autoload_register(array($this, 'loadClass')); + } + + public function isLoaded(string $prefix): bool { + $prefix = trim($prefix, '\\') . '\\'; + return (isset($this->prefixes[$prefix])) ? true : false; + } + + public function getList(): array { + return $this->prefixes; + } + + public function getFilesList(): array { + return $this->loadedFiles; + } + + /** + * Adds a base directory for a namespace prefix. + * + * @param string $prefix The namespace prefix. + * @param string|array $base_dir A base directory for class files in the + * namespace. + * @param bool $prepend If true, prepend the base directory to the stack + * instead of appending it; this causes it to be searched first rather + * than last. + * @return void + */ + public function addNamespace(string $prefix, string|array $baseDir, bool $prepend = false): void { + $prefix = trim($prefix, '\\') . '\\'; + + // Normalize baseDir to always be an array + if (!is_array($baseDir)) { + $baseDir = [$baseDir]; + } + + // Normalize each directory path + $baseDir = array_map(function($dir) { + return rtrim($dir, DIRECTORY_SEPARATOR) . '/'; + }, $baseDir); + + // Initialize array if prefix doesn't exist + if (!isset($this->prefixes[$prefix])) { + $this->prefixes[$prefix] = []; + } + + // Add directories + if ($prepend) { + // Merge and prepend new directories + $this->prefixes[$prefix] = array_merge($baseDir, $this->prefixes[$prefix]); + } else { + // Merge and append new directories + $this->prefixes[$prefix] = array_merge($this->prefixes[$prefix], $baseDir); + } + + // Optional: Remove duplicates while preserving order + $this->prefixes[$prefix] = array_values(array_unique($this->prefixes[$prefix])); +} + +/** + * Loads the class file for a given class name. + * + * @param string $class The fully-qualified class name. + * @return mixed The mapped file name on success, or boolean false on + * failure. + */ +protected function loadClass(string $class): false|string { + if (!strrpos($class, '\\')) { + $ret = $this->loadMappedFile($class . '\\', $class); + if ($ret !== false) { + return $ret; + } + } + + $prefix = $class; + while (false !== $pos = strrpos($prefix, '\\')) { + // retain the trailing namespace separator in the prefix + $prefix = substr($class, 0, $pos + 1); + $relativeClass = substr($class, $pos + 1); + + $mappedFile = $this->loadMappedFile($prefix, $relativeClass); + if ($mappedFile) { + return $mappedFile; + } + + // remove the trailing namespace separator for the next iteration + $prefix = rtrim($prefix, '\\'); + } + + // never found a mapped file + return false; +} + +/** + * Load the mapped file for a namespace prefix and relative class. + * + * @param string $prefix The namespace prefix + * @param string $relativeClass The relative class name + * @return false|string Boolean false if no mapped file can be loaded, + * or the name of the mapped file that was loaded + */ +protected function loadMappedFile(string $prefix, string $relativeClass): false|string { + // Check if there are any base directories for this namespace prefix + if (!isset($this->prefixes[$prefix])) { + return false; + } + + // Iterate through all base directories for this prefix + foreach ($this->prefixes[$prefix] as $baseDir) { + // Replace namespace separators with directory separators + $file = str_replace('\\', '/', $relativeClass) . '.php'; + + // If the mapped file exists, require it + if ($this->requireFile($baseDir, $file)) { + return $file; + } + } + + // None of the directories contained the file + return false; +} + + /** + * If a file exists, require it from the file system. + * + * @param string $file The file to require. + * @return bool True if the file exists, false if not. + */ + private function requireFile(string $path, string $file): bool { + if ($file === "Framework/Requires.php") { + return true; // Already used... + } + $req_class = \IOcornerstone\Framework\Requires::class; + if (! method_exists($req_class, "saferFileExists")) { + require_once IO_CORNERSTONE_FRAMEWORK . DIRECTORY_SEPARATOR . "Framework" . DIRECTORY_SEPARATOR . "Requires.php"; + } + $saferFile = $req_class::saferFileExists($file, $path); + if ($saferFile !== false) { + if (defined('CountFiles') && CountFiles) { + if (! isset($this->loadedFiles[$saferFile])) { + require $saferFile; + $this->loadedFiles[$saferFile] = true; + } + } else { + require_once $saferFile; + } + return true; + } + return false; + } + +} diff --git a/src/composer.json b/src/composer.json new file mode 100644 index 0000000..b571ea3 --- /dev/null +++ b/src/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "psr/log": "^3.0", + "psr/container": "^2.0", + "psr/http-server-middleware": "^1.0", + "psr/http-message": "^2.0" + } +} diff --git a/src/composer.lock b/src/composer.lock new file mode 100644 index 0000000..f9255c2 --- /dev/null +++ b/src/composer.lock @@ -0,0 +1,288 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e1055315e743298ad7d8328c70772d6a", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +}