diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22c2b04 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# .gitattributes + +.github export-ignore +.gitattributes export-ignore +.gitignore export-ignore +tests export-ignore +CHANGELOG.md export-ignore +LICENSE.md export-ignore +phpcs.xml export-ignore +phpunit.xml export-ignore +README.md export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index a0d7e73..445ac00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ .idea -/config/ +config files -Scheduler/Tests storage /vendor/ .env +.env.development +.env.local +.env.production +.env.staging .phpcs-cache .php-cs-fixer.cache +.phpunit.cache .phpunit.result.cache composer.lock index.php \ No newline at end of file diff --git a/Bootstrap/RegisterProviders.php b/Bootstrap/RegisterProviders.php deleted file mode 100644 index 73475bf..0000000 --- a/Bootstrap/RegisterProviders.php +++ /dev/null @@ -1,80 +0,0 @@ -mergeAdditionalProviders($app); - $app->registerConfiguredServiceProviders(); - } - - /** - * Merge additional configured providers into the configuration. - * - * @param Application $app - * @return void - * @throws Exception - */ - protected function mergeAdditionalProviders(Application $app): void - { - /** @var ConfigContainer $config */ - $config = $app->make(name: 'codefy.config'); - - $arrayMerge = array_unique( - array_merge( - self::$merge, - $config->getConfigKey(key: 'app.providers'), - ) - ); - - $config->setConfigKey( - 'app', - [ - 'providers' => $arrayMerge, - ] - ); - } - - /** - * Merge the given providers into the provider configuration - * before registration. - * - * @param array $providers - * @return void - */ - public static function merge(array $providers): void - { - self::$merge = array_values( - array_filter( - array_unique( - array_merge(self::$merge, $providers) - ) - ) - ); - } - - public static function flushState(): void - { - self::$merge = []; - } -} diff --git a/Configuration/ApplicationBuilder.php b/Configuration/ApplicationBuilder.php deleted file mode 100644 index 8cf41dc..0000000 --- a/Configuration/ApplicationBuilder.php +++ /dev/null @@ -1,98 +0,0 @@ -app->singleton( - \Codefy\Framework\Contracts\Kernel::class, - fn() => $this->app->make(name: \Codefy\Framework\Http\Kernel::class) - ); - - $this->app->singleton( - key: \Codefy\Framework\Console\Kernel::class, - value: fn() => $this->app->make(name: \Codefy\Framework\Console\ConsoleKernel::class) - ); - - return $this; - } - - /** - * Register additional service providers. - * - * @param array $providers - * @return $this - * @throws TypeException - */ - public function withProviders(array $providers = []): self - { - RegisterProviders::merge($providers); - - foreach ($providers as $provider) { - $this->app->registerServiceProvider($provider); - } - - return $this; - } - - /** - * Register an array of singletons that are resource intensive - * or are not called often. - * - * @param array $singletons - * @return $this - */ - public function withSingletons(array $singletons = []): self - { - if (empty($singletons)) { - return $this; - } - - foreach ($singletons as $key => $callable) { - $this->app->singleton($key, $callable); - }; - - return $this; - } - - /** - * Register a callback to be invoked when the application's - * service providers are registered. - * - * @param callable $callback - * @return $this - */ - public function registered(callable $callback): self - { - $this->app->registered(callback: $callback); - - return $this; - } - - /** - * Return the application instance. - * - * @return Application - */ - public function return(): Application - { - return $this->app; - } -} diff --git a/Contracts/Kernel.php b/Contracts/Kernel.php deleted file mode 100644 index abd6bf2..0000000 --- a/Contracts/Kernel.php +++ /dev/null @@ -1,22 +0,0 @@ -getContainer(); - } - return $app->getContainer()->make($name, $args); -} - -/** - * Get the available config instance. - * - * @param string $key - * @param array|bool $set - * @return mixed - * @throws TypeException - */ -function config(string $key, array|bool $set = false): mixed -{ - if (!is_false__(var: $set)) { - app(name: Collection::class)->setConfigKey($key, $set); - return app(name: Collection::class)->getConfigKey($key); - } - - return app(name: Collection::class)->getConfigKey($key); -} - -/** - * Retrieve a fresh instance of the bootstrap. - * - * @return mixed - */ -function get_fresh_bootstrap(): mixed -{ - if (file_exists(filename: $file = getcwd() . '/bootstrap/app.php')) { - return require(realpath(path: $file)); - } elseif (file_exists(filename: $file = dirname(path: getcwd()) . '/bootstrap/app.php')) { - return require(realpath(path: $file)); - } else { - return require(realpath(path: dirname(path: getcwd()) . '/bootstrap/app.php')); - } -} - -/** - * Gets the value of an environment variable. - * - * @param string $key - * @param mixed|null $default - * @return mixed|null - */ -function env(string $key, mixed $default = null): mixed -{ - return $_ENV[$key] ?? $default; -} - -/** - * OrmBuilder database instance. - * - * @return OrmBuilder|null - * @throws Exception - */ -function orm(): ?OrmBuilder -{ - return Codefy::$PHP->getDB(); -} - -/** - * Dbal database instance. - * - * @return Connection - * @throws Exception - */ -function dbal(): Connection -{ - return Codefy::$PHP->getDbConnection(); -} - -/** - * Alternative to PHP's native mail function with SMTP support. - * - * This is a simple mail function to see for testing or for - * sending simple email messages. - * - * @param string|array $to Recipient(s) - * @param string $subject Subject of the email. - * @param string $message The email body. - * @param array $headers An array of headers. - * @param array $attachments An array of attachments. - * @return bool - * @throws Exception|ReflectionException|\PHPMailer\PHPMailer\Exception - */ -function mail(string|array $to, string $subject, string $message, array $headers = [], array $attachments = []): bool -{ - // Instantiate CodefyMailer. - $instance = new CodefyMailer(config: app(name: 'codefy.config')); - - // Set the mailer transport. - $func = sprintf('with%s', ucfirst(config(key: 'mailer.mail_transport'))); - $instance = $instance->{$func}(); - - // Detect HTML markdown. - if (substr_count(haystack: $message, needle: '= 1) { - $instance = $instance->withHtml(isHtml: true); - } - - // Build recipient(s). - $instance = $instance->withTo(address: $to); - - // Set from name and from email from environment variables. - $fromName = __observer()->filter->applyFilter('mail.from.name', env(key: 'MAILER_FROM_NAME')); - $fromEmail = __observer()->filter->applyFilter('mail.from.email', env(key: 'MAILER_FROM_EMAIL')); - // Set charset - $charset = __observer()->filter->applyFilter('mail.charset', 'utf-8'); - - // Set email subject and body. - $instance = $instance->withSubject(subject: $subject)->withBody(data: $message); - - // Check for other headers and loop through them. - if (!empty($headers)) { - foreach ($headers as $name => $content) { - if ($name === 'cc') { - $instance = $instance->withCc(address: $content); - } - - if ($name === 'bcc') { - $instance = $instance->withBcc(address: $content); - } - - if ($name === 'replyTo') { - $instance = $instance->withReplyTo(address: $content); - } - - if ( - ! in_array(needle: $name, haystack: ['MIME-Version','to','cc','bcc','replyTo'], strict: true) - && !is_int($name) - ) { - $instance = $instance->withCustomHeader(name: (string) $name, value: $content); - } - } - } - - // Set X-Mailer header - $xMailer = __observer()->filter->applyFilter( - 'mail.xmailer', - sprintf('CodefyPHP Framework %s', Application::APP_VERSION) - ); - $instance = $instance->withXMailer(xmailer: $xMailer); - - // Set email charset - $instance = $instance->withCharset(charset: $charset ?: 'utf-8'); - - // Check if there are attachments and loop through them. - if (!empty($attachments)) { - foreach ($attachments as $filename => $filepath) { - $filename = is_string(value: $filename) ? $filename : ''; - $instance = $instance->withAttachment(path: $filepath, name: $filename); - } - } - - // Set sender. - $instance = $instance->withFrom(address: $fromEmail, name: $fromName ?: ''); - - try { - return $instance->send(); - } catch (\PHPMailer\PHPMailer\Exception $e) { - FileLoggerFactory::getLogger()->error($e->getMessage(), ['function' => '\Codefy\Framework\Helpers\mail']); - return false; - } -} - -/** - * Generate url's from named routes. - * - * @param string $name Name of the route. - * @param array $params Data parameters. - * @return string The url. - * @throws NamedRouteNotFoundException - * @throws RouteParamFailedConstraintException - */ -function route(string $name, array $params = []): string -{ - /** @var Router $route */ - $route = app('router'); - return $route->url($name, $params); -} diff --git a/Migration/Adapter/DbalMigrationAdapter.php b/Migration/Adapter/DbalMigrationAdapter.php deleted file mode 100644 index 5b277a4..0000000 --- a/Migration/Adapter/DbalMigrationAdapter.php +++ /dev/null @@ -1,109 +0,0 @@ -connection; - } - - /** - * Get all migrated version numbers - * - * @return array - * @throws Exception - */ - public function fetchAll(): array - { - $tableName = $this->connection->quoteIdentifier($this->tableName); - $sql = DB::query(query: "SELECT version FROM $tableName ORDER BY version ASC", type: DB::SELECT)->asAssoc(); - $all = $sql->execute($this->connection); - - return array_map(fn ($v) => $v['version'], $all); - } - - /** - * Up - * - * @param Migration $migration - * @return MigrationAdapter - */ - public function up(Migration $migration): MigrationAdapter - { - $this->connection->insert($this->tableName) - ->values( - [ - 'version' => $migration->getVersion(), - 'recorded_on' => (new QubusDateTimeImmutable(time: 'now'))->format(format: 'Y-m-d h:i:s') - ] - )->execute(); - - return $this; - } - - /** - * Down - * - * @param Migration $migration - * @return MigrationAdapter - */ - public function down(Migration $migration): MigrationAdapter - { - $this->connection->delete($this->tableName) - ->where('version', $migration->getVersion()) - ->execute(); - - return $this; - } - - /** - * Is the schema ready? - * - * @return bool - * @throws Exception - */ - public function hasSchema(): bool - { - $tables = $this->connection->listTables(); - - if (in_array(needle: $this->tableName, haystack: $tables)) { - return true; - } - - return false; - } - - /** - * Create Schema - * - * @return MigrationAdapter - */ - public function createSchema(): MigrationAdapter - { - $this->connection->schema()->create($this->tableName, function (CreateTable $table) { - $table->integer(name: 'id')->size(value: 'big')->autoincrement(); - $table->string(name: 'version', length: 191)->notNull(); - $table->dateTime(name: 'recorded_on'); - }); - - return $this; - } -} diff --git a/Migration/Adapter/FileMigrationAdapter.php b/Migration/Adapter/FileMigrationAdapter.php deleted file mode 100644 index 2a7527c..0000000 --- a/Migration/Adapter/FileMigrationAdapter.php +++ /dev/null @@ -1,103 +0,0 @@ -filename = $filename; - } - - /** - * {@inheritdoc} - */ - public function fetchAll(): array - { - $versions = file(filename: $this->filename, flags: FILE_IGNORE_NEW_LINES); - sort($versions); - return $versions; - } - - /** - * {@inheritdoc} - * @throws TypeException - */ - public function up(Migration $migration): MigrationAdapter - { - $versions = $this->fetchAll(); - if (in_array(needle: $migration->getVersion(), haystack: $versions)) { - return $this; - } - - $versions[] = $migration->getVersion(); - $this->write(versions: $versions); - return $this; - } - - /** - * {@inheritdoc} - * @throws TypeException - */ - public function down(Migration $migration): MigrationAdapter - { - $versions = $this->fetchAll(); - if (!in_array(needle: $migration->getVersion(), haystack: $versions)) { - return $this; - } - - unset($versions[array_search(needle: $migration->getVersion(), haystack: $versions)]); - $this->write(versions: $versions); - return $this; - } - - /** - * {@inheritdoc} - */ - public function hasSchema(): bool - { - return file_exists(filename: $this->filename); - } - - /** - * {@inheritdoc} - * @throws TypeException - */ - public function createSchema(): MigrationAdapter - { - if (!is_writable(filename: dirname(path: $this->filename))) { - throw new TypeException(message: sprintf('The file "%s" is not writeable', $this->filename)); - } - - if (false === touch(filename: $this->filename)) { - throw new TypeException(message: sprintf('The file "%s" could not be written to', $this->filename)); - } - - return $this; - } - - /** - * Write to file - * - * @param array $versions - * @throws TypeException - */ - protected function write(array $versions): void - { - if (false === file_put_contents(filename: $this->filename, data: implode(separator: "\n", array: $versions))) { - throw new TypeException(message: sprintf('The file "%s" could not be written to', $this->filename)); - } - } -} diff --git a/Migration/Adapter/MigrationAdapter.php b/Migration/Adapter/MigrationAdapter.php deleted file mode 100644 index 5a71743..0000000 --- a/Migration/Adapter/MigrationAdapter.php +++ /dev/null @@ -1,47 +0,0 @@ -version = $version; - } - - /** - * Init. - * - * @return void - */ - public function init(): void - { - return; - } - - /** - * Do the migration. - * - * @return void - */ - public function up(): void - { - return; - } - - /** - * Undo the migration. - * - * @return void - */ - public function down(): void - { - return; - } - - /** - * Get adapter. - * - * @return MigrationAdapter - */ - public function getAdapter(): MigrationAdapter - { - return $this->get('phpmig.adapter'); - } - - /** - * Get Version. - * - * @return int|string|null - */ - public function getVersion(): int|string|null - { - return $this->version; - } - - /** - * Set version. - * - * @param int|string $version - * @return Migration - */ - public function setVersion(int|string $version): static - { - $this->version = $version; - return $this; - } - - /** - * Get name. - * - * @return string - */ - public function getName(): string - { - return get_class(object: $this); - } - - /** - * Get ObjectMap. - * - * @return ArrayAccess - */ - public function getObjectMap(): ArrayAccess - { - return $this->objectmap; - } - - /** - * Set ObjectMap. - * - * @param ArrayAccess $objectmap - * @return Migration - */ - public function setObjectMap(ArrayAccess $objectmap): static - { - $this->objectmap = $objectmap; - return $this; - } - - - /** - * Get Output. - * - * @return OutputInterface|null - */ - public function getOutput(): ?OutputInterface - { - return $this->output; - } - - /** - * Set Output. - * - * @param OutputInterface $output - * @return Migration - */ - public function setOutput(OutputInterface $output): static - { - $this->output = $output; - return $this; - } - - /** - * Get Input. - * - * @return InputInterface|null - */ - public function getInput(): ?InputInterface - { - return $this->input; - } - - /** - * Set Input. - * - * @param InputInterface $input - * @return Migration - */ - public function setInput(InputInterface $input): static - { - $this->input = $input; - return $this; - } - - /** - * Ask for input. - * - * @param Question $question - * @return mixed - */ - public function ask(Question $question): string - { - return $this->getDialogHelper()->ask(input: $this->getInput(), output: $this->getOutput(), question: $question); - } - - /** - * Get something from the objectmap - * - * @param string $key - * @return mixed - */ - public function get(string $key): mixed - { - $c = $this->getObjectMap(); - return $c[$key]; - } - - /** - * Get Dialog Helper. - * - * @return QuestionHelper|null - */ - public function getDialogHelper(): ?QuestionHelper - { - if ($this->dialogHelper) { - return $this->dialogHelper; - } - - return $this->dialogHelper = new QuestionHelper(); - } - - /** - * Set Dialog Helper. - * - * @param QuestionHelper $dialogHelper - * @return Migration - */ - public function setDialogHelper(QuestionHelper $dialogHelper): static - { - $this->dialogHelper = $dialogHelper; - return $this; - } - - public function schema(): Schema - { - return $this->getAdapter()->connection()->schema(); - } -} diff --git a/Migration/Migrator.php b/Migration/Migrator.php deleted file mode 100644 index ed582cd..0000000 --- a/Migration/Migrator.php +++ /dev/null @@ -1,159 +0,0 @@ -objectmap = $objectmap; - $this->adapter = $adapter; - $this->output = $output; - } - - /** - * Run the up method on a migration - * - * @param Migration $migration - * @return void - */ - public function up(Migration $migration): void - { - $this->run(migration: $migration, direction: 'up'); - } - - /** - * Run the down method on a migration - * - * @param Migration $migration - * @return void - */ - public function down(Migration $migration): void - { - $this->run(migration: $migration, direction: 'down'); - } - - /** - * Run a migration in a particular direction - * - * @param Migration $migration - * @param string $direction - * @return void - */ - protected function run(Migration $migration, string $direction = 'up'): void - { - $direction = ($direction == 'down' ? 'down' : 'up'); - $this->getOutput()?->writeln( - messages: sprintf( - ' == ' . - $migration->getVersion() . ' ' . - $migration->getName() . ' ' . - '' . - ($direction == 'up' ? 'migrating' : 'reverting') . - '' - ) - ); - $start = microtime(as_float: true); - $migration->setObjectMap($this->getObjectMap()); - $migration->init(); - $migration->{$direction}(); - $this->getAdapter()->{$direction}($migration); - $end = microtime(as_float: true); - $this->getOutput()?->writeln( - messages: sprintf( - ' == ' . - $migration->getVersion() . ' ' . - $migration->getName() . ' ' . - '' . - ($direction == 'up' ? 'migrated ' : 'reverted ') . - sprintf("%.4fs", $end - $start) . - '' - ) - ); - } - - /** - * Get ObjectMap. - * - * @return ArrayAccess - */ - public function getObjectMap(): ArrayAccess - { - return $this->objectmap; - } - - /** - * Set ObjectMap. - * - * @param ArrayAccess $objectmap - * @return Migrator - */ - public function setObjectMap(ArrayAccess $objectmap): static - { - $this->objectmap = $objectmap; - return $this; - } - - /** - * Get Adapter - * - * @return MigrationAdapter|null - */ - public function getAdapter(): ?MigrationAdapter - { - return $this->adapter; - } - - /** - * Set Adapter - * - * @param MigrationAdapter $adapter - * @return Migrator - */ - public function setAdapter(MigrationAdapter $adapter): static - { - $this->adapter = $adapter; - return $this; - } - - /** - * Get Output - * - * @return OutputInterface|null - */ - public function getOutput(): ?OutputInterface - { - return $this->output; - } - - /** - * Set Output - * - * @param OutputInterface $output - * @return Migrator - */ - public function setOutput(OutputInterface $output): static - { - $this->output = $output; - return $this; - } -} diff --git a/Providers/PdoServiceProvider.php b/Providers/PdoServiceProvider.php deleted file mode 100644 index 1ce5488..0000000 --- a/Providers/PdoServiceProvider.php +++ /dev/null @@ -1,50 +0,0 @@ -codefy->make('codefy.config'); - - $default = $config->getConfigKey(key: 'database.default'); - $dsn = sprintf( - '%s:dbname=%s;host=%s;charset=utf8mb4', - $config->getConfigKey(key: "database.connections.{$default}.driver"), - $config->getConfigKey(key: "database.connections.{$default}.dbname"), - $config->getConfigKey(key: "database.connections.{$default}.host") - ); - - $this->codefy->define(name: PDO::class, args: [ - ':dsn' => $dsn, - ':username' => $config->getConfigKey( - key: "database.connections.{$default}.username" - ), - ':password' => $config->getConfigKey( - key: "database.connections.{$default}.password" - ), - ':options' => [ - PDO::ATTR_EMULATE_PREPARES => false, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_PERSISTENT => false, - PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" - ], - ]); - - $this->codefy->share(nameOrInstance: PDO::class); - } -} diff --git a/README.md b/README.md index feb4743..534674c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Latest Stable Version - PHP 8.2 + PHP 8.4 License Total Downloads CodefyPHP Support Forum @@ -16,8 +16,8 @@ CodefyPHP is __not__ a framework such as the likes of Symfony, Laravel, Codeigni light framework providing interfaces and implementations for architecting a Domain Driven project with CQRS, Event Sourcing and implementations of [PSR-3](https://www.php-fig.org/psr/psr-3), [PSR-6](https://www.php-fig.org/psr/psr-6), [PSR-7](https://www.php-fig.org/psr/psr-7), -[PSR-11](https://www.php-fig.org/psr/psr-11), [PSR-12](https://www.php-fig.org/psr/psr-12/), -[PSR-15](https://www.php-fig.org/psr/psr-15), [PSR-16](https://www.php-fig.org/psr/psr-16) +[PSR-11](https://www.php-fig.org/psr/psr-11), [PSR-12](https://www.php-fig.org/psr/psr-12/), +[PSR-14](https://www.php-fig.org/psr/psr-14/), [PSR-15](https://www.php-fig.org/psr/psr-15), [PSR-16](https://www.php-fig.org/psr/psr-16) and [PSR-17](https://www.php-fig.org/psr/psr-17). The philosophy of Codefy is that code should be systematized, maintainable, and follow OOP (Object-Oriented Programming). @@ -30,7 +30,7 @@ none of the features and instead use the interfaces to build your own implementa ## 📍 Requirement -- PHP 8.2+ +- PHP 8.4+ - Additional constraints based on which components are used. ## 🏆 Highlighted Features @@ -61,7 +61,7 @@ composer require codefyphp/codefy |---------|---------------------|----------------|-----------------|----------------------| | 1 | 8.2 | September 2023 | July 2024 | EOL | | 2 - LTS | 8.2 | September 2024 | September 2027 | January 2028 | -| 3.0 | 8.4 | December 2025 | August 2026 | December 2027 | +| 3.0 | 8.4 | October 2025 | August 2026 | December 2027 | | 3.1 | 8.4 | June 2025 | February 2027 | June 2028 | ## 📘 Documentation diff --git a/Scheduler/BaseTask.php b/Scheduler/BaseTask.php deleted file mode 100644 index 253ef6f..0000000 --- a/Scheduler/BaseTask.php +++ /dev/null @@ -1,283 +0,0 @@ -options = $options + [ - 'schedule' => null, - 'maxRuntime' => 120, - 'recipients' => null, - 'smtpSender' => null, - 'smtpSenderName' => null, - 'enabled' => false, - ]; - - if ($this->options['schedule'] instanceof Expressional) { - $this->expression = $this->options['schedule']; - } - - if (is_string($this->options['schedule'])) { - $this->expression = $this->alias($this->options['schedule']); - } - - return $this; - } - - public function getOption(?string $option): mixed - { - return $this->options[$option]; - } - - public function withTimezone(?string $timeZone): self - { - $this->timeZone = $timeZone; - - return $this; - } - - /** - * @throws TypeException - */ - public function pid(): TaskId - { - $this->pid = TaskId::fromString(); - - return $this->pid; - } - - /** - * Called before a task is executed. - */ - public function setUp(): void - { - } - - /** - * Executes a task. - */ - abstract public function execute(Schedule $schedule): void; - - /** - * Called after a task is executed. - */ - public function tearDown(): void - { - } - - /** - * Check if the Task is overlapping. - * @throws TypeException - */ - public function isOverlapping(): bool - { - return $this->acquireLock && - $this->cache->hasItem($this->pid()) && - call_user_func($this->whenOverlapping, $this->lockValue) === false; - } - - /** - * Check if time hasn't arrived. - * - * @param string $datetime - * @return bool - */ - protected function notYet(string $datetime): bool - { - return time() < strtotime($datetime); - } - - /** - * Check if the time has passed. - * - * @param string $datetime - * @return bool - */ - protected function past(string $datetime): bool - { - return time() > strtotime($datetime); - } - - /** - * Check if event should be run. - * - * @param string $datetime - * @return self - */ - public function from(string $datetime): self - { - return $this->skip(function () use ($datetime) { - return $this->notYet($datetime); - }); - } - - /** - * Check if event should be not run. - * - * @param string $datetime - * @return self - */ - public function to(string $datetime): self - { - return $this->skip(function () use ($datetime) { - return $this->past($datetime); - }); - } - - /** - * Acquires the Task lock. - * @throws TypeException - */ - protected function acquireTaskLock(): void - { - if ($this->acquireLock) { - $item = $this->cache->getItem($this->pid()); - - if (!$item->isHit()) { - $item->set($this->options['maxRuntime'] + time()); - $item->expiresAfter($this->options['maxRuntime']); - $this->cache->save($item); - } - - $this->lockValue = $item->get(); - } - } - - /** - * Removes the Task lock. - * @throws TypeException - */ - protected function removeTaskLock(): bool - { - return $this->cache->deleteItem($this->pid()); - } - - /** - * @throws TypeException - * @throws Exception - */ - public function checkMaxRuntime(): void - { - $maxRuntime = $this->options['maxRuntime']; - if (is_null__($maxRuntime)) { - return; - } - - $runtime = $this->getLockLifetime($this->pid()); - if ($runtime < $maxRuntime) { - return; - } - - throw new Exception( - sprintf( - 'Max Runtime of %s secs exceeded! Current runtime for %s: %s secs.', - $maxRuntime, - static::class, - $runtime - ), - 8712 - ); - } - - /** - * Retrieve the Task lock lifetime value. - */ - private function getLockLifetime(TaskId $lockKey): int - { - if (!$this->cache->hasItem($lockKey)) { - return 0; - } - - return time() - $this->lockValue; - } - - /** - * Checks whether the task can and should run. - * @throws TypeException - */ - private function shouldRun(): bool - { - if (is_false__($this->options['enabled'])) { - return false; - } - - // If task overlaps don't execute. - if ($this->isOverlapping()) { - return false; - } - - return true; - } - - /** - * Checks whether the task can and should run. - * @throws TypeException - * @throws NativeException - */ - public function isDue(string|DateTimeZone|null $timeZone = null): bool - { - if (is_false__($this->shouldRun())) { - return false; - } - - if (is_null__($timeZone)) { - $timeZone = date_default_timezone_get(); - } - - $isDue = $this->expression->isDue(currentTime: 'now', timeZone: $this->timeZone ?? $timeZone); - - if (parent::isDue($this->timeZone ?? $timeZone) && $isDue) { - return true; - } - - return false; - } -} diff --git a/Scheduler/Event/TaskCompleted.php b/Scheduler/Event/TaskCompleted.php deleted file mode 100644 index 2988675..0000000 --- a/Scheduler/Event/TaskCompleted.php +++ /dev/null @@ -1,25 +0,0 @@ -task = $task; - } - - public function getTask(): Task - { - return $this->task; - } -} diff --git a/Scheduler/Event/TaskFailed.php b/Scheduler/Event/TaskFailed.php deleted file mode 100644 index ceaf433..0000000 --- a/Scheduler/Event/TaskFailed.php +++ /dev/null @@ -1,25 +0,0 @@ -task = $task; - } - - public function getTask(): Task - { - return $this->task; - } -} diff --git a/Scheduler/Event/TaskSkipped.php b/Scheduler/Event/TaskSkipped.php deleted file mode 100644 index ca60923..0000000 --- a/Scheduler/Event/TaskSkipped.php +++ /dev/null @@ -1,12 +0,0 @@ -task = $task; - } - - public function getTask(): Task - { - return $this->task; - } -} diff --git a/Scheduler/FailedProcessor.php b/Scheduler/FailedProcessor.php deleted file mode 100644 index 7ae4734..0000000 --- a/Scheduler/FailedProcessor.php +++ /dev/null @@ -1,29 +0,0 @@ -processor; - } - - public function getException(): Exception - { - return $this->exception; - } -} diff --git a/Scheduler/Stack.php b/Scheduler/Stack.php deleted file mode 100644 index f7c47e2..0000000 --- a/Scheduler/Stack.php +++ /dev/null @@ -1,133 +0,0 @@ -dispatcher = $dispatcher; - $this->mutex = $mutex; - $this->timezone = $timezone; - } - - /** - * Add tasks to the list. - * - * @param Task[] $tasks - */ - public function addTasks(array $tasks): void - { - foreach ($tasks as $task) { - $this->addTask($task); - } - } - - /** - * Add a task to the list. - */ - public function addTask(Task $task): Stack - { - $this->tasks[] = $task; - - return $this; - } - - /** - * Returns an array of tasks. - */ - public function tasks(): array - { - return $this->tasks; - } - - /** - * Gets tasks that are due and can run. - * - * @return array - */ - public function tasksDue(): array - { - $that = clone $this; - - return array_filter($this->tasks, function (Task $task) use ($that) { - return $task->isDue($that->timezone); - }); - } - - public function withOptions(array $options = []): self - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - - $this->options = $resolver->resolve($options); - - return $this; - } - - protected function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'dispatcher' => $this->dispatcher, - 'mutex' => $this->mutex, - 'timezone' => $this->timezone, - 'maxRuntime' => 120, - 'encryption' => null, - ]); - } - - public function run(): void - { - $schedule = new Schedule($this->options['timezone'], $this->options['mutex']); - - foreach ($this->tasks() as $task) { - if (is_false__($task->getOption('enabled'))) { - continue; - } - - $this->dispatcher->dispatch(TaskStarted::EVENT_NAME); - - try { - // Run code before executing task. - $task->setUp(); - // Execute task. - $task->execute($schedule); - // Run code after executing task. - $task->tearDown(); - } catch (Exception $ex) { - $this->dispatcher->dispatch(TaskFailed::EVENT_NAME); - $this->sendEmail($ex); - } - - $this->dispatcher->dispatch(TaskCompleted::EVENT_NAME); - } - } -} diff --git a/Scheduler/Task.php b/Scheduler/Task.php deleted file mode 100644 index ddec023..0000000 --- a/Scheduler/Task.php +++ /dev/null @@ -1,44 +0,0 @@ -=8.2", + "php": ">=8.4", "ext-pdo": "*", - "codefyphp/domain-driven-core": "^1", + "codefyphp/domain-driven-core": "^3", + "composer/class-map-generator": "^1", "dragonmantank/cron-expression": "^3", - "qubus/cache": "^3", - "qubus/error": "^2", - "qubus/event-dispatcher": "^3", - "qubus/exception": "^3", - "qubus/expressive": "^1", - "qubus/filesystem": "^3", - "qubus/inheritance": "^3", - "qubus/injector": "^3", - "qubus/mail": "^4", - "qubus/router": "^3", - "qubus/security": "^3", - "qubus/support": "^3", - "qubus/view": "^2", - "symfony/console": "^6", - "symfony/options-resolver": "^6" + "melbahja/seo": "^2", + "middlewares/cache": "^3", + "middlewares/minifier": "^2", + "middlewares/referrer-spam": "^2", + "middlewares/whoops": "^2", + "paragonie/csp-builder": "^3", + "php-debugbar/php-debugbar": "^2.2", + "qubus/cache": "^4", + "qubus/error": "^3", + "qubus/event-dispatcher": "^4", + "qubus/exception": "^4", + "qubus/expressive": "^3", + "qubus/filesystem": "^4", + "qubus/inheritance": "^4", + "qubus/mail": "^5", + "qubus/nosql": "^4", + "qubus/router": "^4", + "qubus/security": "^4", + "qubus/support": "^4", + "qubus/view": "^3", + "symfony/console": "^7", + "symfony/options-resolver": "^7" }, "autoload": { "psr-4": { - "Codefy\\Framework\\": "" + "Codefy\\Framework\\": "src/" }, "files": [ - "Helpers/core.php", - "Helpers/path.php" + "src/Helpers/core.php", + "src/Helpers/path.php", + "src/Http/Middleware/Csrf/helpers.php" ] }, "require-dev": { "fenom/fenom": "^3.0", "fenom/providers-collection": "^1.0", "foil/foil": "^0.6.7", - "mockery/mockery": "^1.3.1", - "pestphp/pest": "^1.22", - "pestphp/pest-plugin-mock": "^1.0", - "qubus/qubus-coding-standard": "^1.1" + "qubus/qubus-coding-standard": "^2" + }, + "autoload-dev": { + "psr-4": { + "Codefy\\Framework\\Tests\\": "tests/" + } }, "scripts": { - "test": "XDEBUG_MODE=coverage vendor/bin/pest --coverage --min=50 --colors=always", + "test": "vendor/bin/pest", "cs-check": "phpcs", "cs-fix": "phpcbf" }, diff --git a/phpcs.xml b/phpcs.xml index 1b99f57..9a0285f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -12,20 +12,23 @@ - Auth - Bootstrap - Console - Contracts - Factory - Helpers - Http - Migration - Providers - Scheduler - Support - View - Application.php - Codefy.php + src/Auth + src/Bootstrap + src/Configuration + src/Console + src/Contracts + src/Factory + src/Helpers + src/Http + src/Migration + src/Pipeline + src/Providers + src/Proxy + src/Scheduler + src/Support + src/Traits + src/View + src/Application.php diff --git a/phpunit.xml b/phpunit.xml index 2ec8627..b1bedaf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,17 @@ - - - - ./tests - - - - - ./vendor - ./tests - - - ./ - - + + + + ./tests + + + + + ./src + + + ./vendor + ./tests + + diff --git a/Application.php b/src/Application.php similarity index 69% rename from Application.php rename to src/Application.php index 03eae67..a278a40 100644 --- a/Application.php +++ b/src/Application.php @@ -4,27 +4,34 @@ namespace Codefy\Framework; +use Codefy\Framework\Bootstrap\RegisterProviders; use Codefy\Framework\Configuration\ApplicationBuilder; +use Codefy\Framework\Contracts\Http\Kernel; use Codefy\Framework\Factory\FileLoggerFactory; -use Codefy\Framework\Factory\FileLoggerSmtpFactory; use Codefy\Framework\Pipeline\PipelineBuilder; +use Codefy\Framework\Proxy\Codefy; +use Codefy\Framework\Support\Assets; use Codefy\Framework\Support\BasePathDetector; use Codefy\Framework\Support\LocalStorage; use Codefy\Framework\Support\Paths; +use Codefy\Framework\Traits\LoggerAware; +use Defuse\Crypto\Exception\BadFormatException; +use Defuse\Crypto\Exception\EnvironmentIsBrokenException; +use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException; use Dotenv\Dotenv; use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; use Qubus\Config\ConfigContainer; -use Qubus\Dbal\Connection; -use Qubus\Dbal\DB; use Qubus\EventDispatcher\ActionFilter\Observer; -use Qubus\EventDispatcher\EventDispatcher; use Qubus\Exception\Data\TypeException; use Qubus\Exception\Exception; -use Qubus\Expressive\OrmBuilder; +use Qubus\Expressive\Connection; +use Qubus\Expressive\Connection\DriverConnection; +use Qubus\Expressive\QueryBuilder; use Qubus\Http\Cookies\Factory\HttpCookieFactory; +use Qubus\Http\Encryption\Env\SecureEnv; use Qubus\Http\Session\Flash; use Qubus\Http\Session\PhpSession; use Qubus\Inheritance\InvokerAware; @@ -35,45 +42,33 @@ use Qubus\Injector\ServiceProvider\Bootable; use Qubus\Injector\ServiceProvider\Serviceable; use Qubus\Mail\Mailer; +use Qubus\Routing\Router; use Qubus\Support\ArrayHelper; -use Qubus\Support\Assets; use Qubus\Support\StringHelper; use ReflectionException; +use function array_merge; +use function array_unique; use function dirname; use function get_class; use function is_string; use function Qubus\Config\Helpers\env; use function Qubus\Support\Helpers\is_null__; use function rtrim; +use function strtoupper; use const DIRECTORY_SEPARATOR; -/** - * @property-read ServerRequestInterface $request - * @property-read ResponseInterface $response - * @property-read Assets $assets - * @property-read Mailer $mailer - * @property-read PhpSession $session - * @property-read Flash $flash - * @property-read EventDispatcher $event - * @property-read HttpCookieFactory $httpCookie - * @property-read LocalStorage $localStorage - * @property-read ConfigContainer $configContainer - * @property-read PipelineBuilder $pipeline - * @property-read Observer $hook - * @property-read StringHelper $string - * @property-read ArrayHelper $array - */ final class Application extends Container { use InvokerAware; + use LoggerAware; - public const APP_VERSION = '2.1.5'; + public const string APP_VERSION = '3.0.0'; - public const MIN_PHP_VERSION = '8.2'; + public const string MIN_PHP_VERSION = '8.4'; - public const DS = DIRECTORY_SEPARATOR; + public const string DS = DIRECTORY_SEPARATOR; /** * The current globally available Application (if any). @@ -82,40 +77,89 @@ final class Application extends Container */ public static ?Application $APP = null; - public string $charset = 'UTF-8'; + // phpcs:disable + /** + * @var string Charset of the application. + */ + public string $charset = 'UTF-8' { + get => $this->charset; + set(string $charset) => $this->charset = strtoupper($charset); + } + + /** + * @var string Language of the application. + */ + public string $language = 'en' { + get => $this->language; + set(string $language) => $this->language = strtoupper($language); + } - public string $locale = 'en'; + /** + * @var string Language of translatable source files. + */ + public string $locale = 'en' { + get => $this->locale; + set(string $locale) => $this->locale = $locale; + } - public string $controllerNamespace = 'App\\Infrastructure\\Http\\Controllers'; + public string $controllerNamespace = 'App\\Infrastructure\\Http\\Controllers' { + get => $this->controllerNamespace; + set(string $controllerNamespace) => $this->controllerNamespace = $controllerNamespace; + } public static string $ROOT_PATH = ''; - protected string $basePath = ''; + public static bool $encryptedEnv = false; + + private string $basePath = '' { + get => $this->basePath; + } - protected ?string $appPath = null; + private ?string $appPath = null { + get => $this->appPath; + } - protected array $serviceProviders = []; + private array $serviceProviders = [] { + &get => $this->serviceProviders; + } - protected array $serviceProvidersRegistered = []; + private array $serviceProvidersRegistered = [] { + &get => $this->serviceProvidersRegistered; + } - protected array $baseMiddlewares = []; + private array $baseMiddlewares = [] { + &get => $this->baseMiddlewares; + } - private bool $booted = false; + public private(set) bool $booted = false; - private bool $hasBeenBootstrapped = false; + private bool $hasBeenBootstrapped = false { + get => $this->hasBeenBootstrapped; + } - protected array $bootingCallbacks = []; + private array $bootingCallbacks = [] { + &get => $this->bootingCallbacks; + } - protected array $bootedCallbacks = []; + private array $bootedCallbacks = [] { + &get => $this->bootedCallbacks; + } - protected array $registeredCallbacks = []; + private array $registeredCallbacks = [] { + &get => $this->registeredCallbacks; + } - private array $param = []; + private array $param = [] { + &get => $this->param; + } + // phpcs:enable /** + * @param array $params + * @throws ReflectionException * @throws TypeException */ - public function __construct(array $params) + public function __construct(array $params = []) { if (isset($params['basePath'])) { $this->withBasePath(basePath: $params['basePath']); @@ -124,7 +168,8 @@ public function __construct(array $params) parent::__construct(InjectorFactory::create(config: $this->coreAliases())); $this->registerBaseBindings(); $this->registerDefaultServiceProviders(); - $this->registerPropertyBindings(); + + Codefy::$PHP = $this::getInstance(); } private function registerBaseBindings(): void @@ -134,38 +179,6 @@ private function registerBaseBindings(): void $this->alias(original: 'app', alias: self::class); } - /** - * Dynamically created properties. - * - * @return void - * @throws TypeException - */ - private function registerPropertyBindings(): void - { - $contracts = [ - 'request' => ServerRequestInterface::class, - 'response' => ResponseInterface::class, - 'assets' => Assets::class, - 'mailer' => Mailer::class, - 'session' => PhpSession::class, - 'flash' => Flash::class, - 'event' => EventDispatcher::class, - 'httpCookie' => HttpCookieFactory::class, - 'localStorage' => Support\LocalStorage::class, - 'configContainer' => ConfigContainer::class, - 'pipeline' => PipelineBuilder::class, - 'hook' => Observer::class, - 'string' => StringHelper::class, - 'array' => ArrayHelper::class, - ]; - - foreach ($contracts as $property => $name) { - $this->{$property} = $this->make(name: $name); - } - - Codefy::$PHP = $this::getInstance(); - } - /** * Infer the application's base directory * from the environment and server. @@ -174,36 +187,15 @@ private function registerPropertyBindings(): void */ protected static function inferBasePath(): ?string { - $basePath = (new BasePathDetector())->getBasePath(); + $basePath = new BasePathDetector()->getBasePath(); return match (true) { - (env('APP_BASE_PATH') !== null && env('APP_BASE_PATH') !== false) => env('APP_BASE_PATH'), + (env(key: 'APP_BASE_PATH') !== null && env(key: 'APP_BASE_PATH') !== false) => env(key: 'APP_BASE_PATH'), $basePath !== '' => $basePath, default => dirname(path: __FILE__, levels: 2), }; } - /** - * FileLogger - * - * @throws ReflectionException - */ - public static function getLogger(): LoggerInterface - { - return FileLoggerFactory::getLogger(); - } - - /** - * FileLogger with SMTP support. - * - * @throws ReflectionException - * @throws TypeException - */ - public static function getSmtpLogger(): LoggerInterface - { - return FileLoggerSmtpFactory::getLogger(); - } - /** * @throws Exception */ @@ -211,31 +203,18 @@ public function getDbConnection(): Connection { /** @var ConfigContainer $config */ $config = $this->make(name: 'codefy.config'); + $default = $config->getConfigKey(key: 'database.default'); - $connection = $config->getConfigKey(key: 'database.default'); - - return DB::connection([ - 'driver' => $config->getConfigKey(key: "database.connections.{$connection}.driver"), - 'host' => $config->getConfigKey(key: "database.connections.{$connection}.host", default: 'localhost'), - 'port' => $config->getConfigKey(key: "database.connections.{$connection}.port", default: 3306), - 'charset' => $config->getConfigKey(key: "database.connections.{$connection}.charset", default: 'utf8mb4'), - 'collation' => $config->getConfigKey( - key: "database.connections.{$connection}.collation", - default: 'utf8mb4_unicode_ci' - ), - 'username' => $config->getConfigKey(key: "database.connections.{$connection}.username"), - 'password' => $config->getConfigKey(key: "database.connections.{$connection}.password"), - 'dbname' => $config->getConfigKey(key: "database.connections.{$connection}.dbname"), - ]); + return DriverConnection::make($config->getConfigKey(key: "database.connections.{$default}")); } /** - * @return OrmBuilder|null + * @return QueryBuilder|null * @throws Exception */ - public function getDB(): ?OrmBuilder + public function getDb(): ?QueryBuilder { - return new OrmBuilder(connection: $this->getDbConnection()); + return QueryBuilder::fromInstance($this->getDbConnection()); } /** @@ -278,8 +257,10 @@ protected function registerDefaultServiceProviders(): void foreach ( [ Providers\ConfigServiceProvider::class, - Providers\PdoServiceProvider::class, + Providers\EventDispatcherServiceProvider::class, + Providers\DatabaseConnectionServiceProvider::class, Providers\FlysystemServiceProvider::class, + Providers\RouterServiceProvider::class, ] as $serviceProvider ) { $this->registerServiceProvider(serviceProvider: $serviceProvider); @@ -292,11 +273,11 @@ public function bootstrapWith(array $bootstrappers): void $this->hasBeenBootstrapped = true; foreach ($bootstrappers as $bootstrapper) { - $this->make(name: EventDispatcher::class)->dispatch("bootstrapping.{$bootstrapper}"); + $this->make(name: EventDispatcherInterface::class)->dispatch(new $bootstrapper()); $this->make(name: $bootstrapper)->bootstrap($this); - $this->make(name: EventDispatcher::class)->dispatch("bootstrapped.{$bootstrapper}"); + $this->make(name: EventDispatcherInterface::class)->dispatch(new $bootstrapper()); } } @@ -305,16 +286,11 @@ public function getContainer(): ServiceContainer|ContainerInterface return $this; } - public function setBoot(bool $bool = false): void + public function setBooted(bool $bool = false): void { $this->booted = $bool; } - public function isBooted(): bool - { - return $this->booted; - } - /** * @return void * @throws TypeException @@ -363,7 +339,8 @@ public function registerConfiguredServiceProviders(): void /** @var ConfigContainer $config */ $config = $this->make(name: 'codefy.config'); - $providers = $config->getConfigKey(key: 'app.providers'); + $appProviders = $config->getConfigKey(key: 'app.providers'); + $providers = array_unique(array_merge($appProviders, RegisterProviders::$providers)); foreach ($providers as $serviceProvider) { $this->registerServiceProvider(serviceProvider: $serviceProvider); @@ -395,10 +372,10 @@ public function registerServiceProvider( $serviceProvider->register(); - $this->markServiceProviderAsRegistered(registered: $registered, serviceProvider: $serviceProvider); + $this->markServiceProviderAsRegistered(serviceProvider: $serviceProvider); // If application is booted, call the boot method on the service provider // if it exists. - if ($this->isBooted()) { + if ($this->booted) { $this->bootServiceProvider(provider: $serviceProvider); }; @@ -408,12 +385,14 @@ public function registerServiceProvider( /** * Get the registered service provider instance if it exists. * - * @param Serviceable|Bootable|string $provider - * @return string|null + * @param Serviceable|Bootable|string $provider + * @return Serviceable|Bootable|null */ - public function getRegisteredServiceProvider(Serviceable|Bootable|string $provider): string|null + public function getRegisteredServiceProvider(Serviceable|Bootable|string $provider): Serviceable|Bootable|null { - return $this->serviceProviders[$provider] ?? null; + $registered = is_string(value: $provider) ? $provider : get_class(object: $provider); + + return $this->serviceProviders[$registered] ?? null; } /** @@ -443,7 +422,7 @@ public function resolveServiceProvider(string $provider): BaseServiceProvider /** * Get the service providers that have been registered. * - * @return array + * @return array */ public function getRegisteredProviders(): array { @@ -453,12 +432,14 @@ public function getRegisteredProviders(): array /** * Determine if the given service provider is registered. * - * @param string $provider + * @param string|Serviceable|Bootable $provider * @return bool */ - public function providerIsRegistered(string $provider): bool + public function providerIsRegistered(string|Serviceable|Bootable $provider): bool { - return isset($this->serviceProvidersRegistered[$provider]); + $registered = is_string(value: $provider) ? $provider : get_class(object: $provider); + + return isset($this->serviceProvidersRegistered[$registered]); } /** @@ -470,9 +451,13 @@ public function providerIsRegistered(string $provider): bool */ protected function bootServiceProvider(Serviceable|Bootable $provider): void { + $provider->callBootingCallbacks(); + if (method_exists(object_or_class: $provider, method: 'boot')) { $this->execute(callableOrMethodStr: [$provider, 'boot']); } + + $provider->callBootedCallbacks(); } /** @@ -496,7 +481,7 @@ public function booted(callable $callback): void { $this->bootedCallbacks[] = $callback; - if ($this->isBooted()) { + if ($this->booted) { $callback($this); } } @@ -521,16 +506,14 @@ protected function fireAppCallbacks(array &$callbacks): void /** * Mark the particular ServiceProvider as having been registered. * - * @param string|null $registered * @param Serviceable|Bootable $serviceProvider * @return void */ - protected function markServiceProviderAsRegistered( - string|null $registered, - Serviceable|Bootable $serviceProvider - ): void { - $this->serviceProviders[$registered] = $serviceProvider; - $this->serviceProvidersRegistered[$registered] = true; + protected function markServiceProviderAsRegistered(Serviceable|Bootable $serviceProvider): void + { + $class = get_class($serviceProvider); + $this->serviceProviders[$class] = $serviceProvider; + $this->serviceProvidersRegistered[$class] = true; } /** @@ -596,7 +579,7 @@ public function withAppPath(string $appPath): self */ public function path(): string { - return $this->appPath ?: $this->basePath . self::DS . 'app'; + return $this->appPath ?: $this->basePath . self::DS . 'App'; } /** @@ -619,6 +602,16 @@ public function bootStrapPath(): string return $this->basePath . self::DS . 'bootstrap'; } + /** + * Get the path to the service provider list in the bootstrap directory. + * + * @return string + */ + public function getBootstrapProvidersPath(): string + { + return $this->bootstrapPath() . self::DS . 'providers.php'; + } + /** * Get the path to the application's "config" directory. * @@ -711,11 +704,6 @@ public function withControllerNamespace(string $namespace): void $this->controllerNamespace = $namespace; } - public function getControllerNamespace(): string - { - return $this->controllerNamespace; - } - public function withBaseMiddlewares(array $middlewares): void { $this->baseMiddlewares = $middlewares; @@ -773,26 +761,23 @@ protected function coreAliases(): array \Psr\Container\ContainerInterface::class => self::class, \Qubus\Injector\ServiceContainer::class => self::class, \Psr\Http\Message\ServerRequestInterface::class => \Qubus\Http\ServerRequest::class, - \Psr\Http\Message\ServerRequestFactoryInterface::class => \Qubus\Http\ServerRequestFactory::class, + \Psr\Http\Message\ServerRequestFactoryInterface::class => \Qubus\Http\Factories\Psr17Factory::class, \Psr\Http\Message\RequestInterface::class => \Qubus\Http\Request::class, + \Psr\Http\Message\RequestFactoryInterface::class => \Qubus\Http\Factories\Psr17Factory::class, \Psr\Http\Server\RequestHandlerInterface::class => \Relay\Runner::class, \Psr\Http\Message\ResponseInterface::class => \Qubus\Http\Response::class, - \Psr\Http\Message\ResponseFactoryInterface::class => \Laminas\Diactoros\ResponseFactory::class, + \Psr\Http\Message\ResponseFactoryInterface::class => \Qubus\Http\Factories\Psr17Factory::class, \Psr\Cache\CacheItemInterface::class => \Qubus\Cache\Psr6\Item::class, \Psr\Cache\CacheItemPoolInterface::class => \Qubus\Cache\Psr6\ItemPool::class, \Qubus\Cache\Psr6\TaggableCacheItem::class => \Qubus\Cache\Psr6\TaggablePsr6ItemAdapter::class, \Qubus\Cache\Psr6\TaggableCacheItemPool::class => \Qubus\Cache\Psr6\TaggablePsr6PoolAdapter::class, \Qubus\Config\Path\Path::class => \Qubus\Config\Path\ConfigPath::class, \Qubus\Config\ConfigContainer::class => \Qubus\Config\Collection::class, - \Qubus\EventDispatcher\EventDispatcher::class => \Qubus\EventDispatcher\Dispatcher::class, \Qubus\Mail\Mailer::class => \Codefy\Framework\Support\CodefyMailer::class, 'mailer' => Mailer::class, 'dir.path' => \Codefy\Framework\Support\Paths::class, 'container' => self::class, 'codefy' => self::class, - \Qubus\Routing\Interfaces\Collector::class => \Qubus\Routing\Route\RouteCollector::class, - 'router' => \Qubus\Routing\Router::class, - \Codefy\Framework\Contracts\Kernel::class => \Codefy\Framework\Http\Kernel::class, \Codefy\Framework\Contracts\RoutingController::class => \Codefy\Framework\Http\BaseController::class, \League\Flysystem\FilesystemOperator::class => \Qubus\FileSystem\FileSystem::class, \League\Flysystem\FilesystemAdapter::class => \Qubus\FileSystem\Adapter\LocalFlysystemAdapter::class, @@ -815,19 +800,49 @@ protected function coreAliases(): array ]; } + /** + * @throws \Exception + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + /** @var Kernel $kernel */ + $kernel = $this->make(name: Kernel::class); + + return $kernel->handle($request); + } + + public function handleRequest(?ServerRequestInterface $request = null): void + { + /** @var Kernel $kernel */ + $kernel = $this->make(name: Kernel::class); + + $kernel->boot($request); + } + /** * Load environment file(s). * + * @param string $basePath * @return void + * @throws TypeException + * @throws ReflectionException */ - private function loadEnvironment(): void + private static function loadEnvironment(string $basePath): void { - $dotenv = Dotenv::createImmutable( - paths: $this->basePath(), - names: ['.env','.env.local','.env.staging','.env.development','.env.production'], - shortCircuit: false - ); - $dotenv->safeLoad(); + if (self::$encryptedEnv) { + try { + SecureEnv::parse(inputFile: $basePath . '/.env.enc', keyFile: $basePath . '/.enc.key'); + } catch (BadFormatException | EnvironmentIsBrokenException | WrongKeyOrModifiedCiphertextException $e) { + FileLoggerFactory::getLogger()->error($e->getMessage()); + } + } else { + $dotenv = Dotenv::createImmutable( + paths: $basePath, + names: ['.env','.env.local','.env.staging','.env.development','.env.production'], + shortCircuit: false + ); + $dotenv->safeLoad(); + } } public function __get(mixed $name) @@ -879,13 +894,16 @@ public function isDevelopment(): bool } /** - * Configure a new CodefyPHP application instance. + * Create a new CodefyPHP application instance. * * @throws TypeException + * @throws ReflectionException */ - public static function configure(array $config): ApplicationBuilder + public static function create(array $config): ApplicationBuilder { - return new ApplicationBuilder(new self($config)); + return new ApplicationBuilder(new self($config)) + ->withKernels() + ->withProviders(); } /** @@ -893,6 +911,7 @@ public static function configure(array $config): ApplicationBuilder * * @return static * @throws TypeException + * @throws ReflectionException */ public static function getInstance(?string $path = null): self { @@ -910,6 +929,74 @@ public static function getInstance(?string $path = null): self ); } + self::loadEnvironment($basePath); + return self::$APP; } + + //phpcs:disable + public private(set) ServerRequestInterface $request { + get => $this->request ?? $this->make(name: ServerRequestInterface::class); + } + + public private(set) ResponseInterface $response { + get => $this->response ?? $this->make(name: ResponseInterface::class); + } + + public private(set) Assets $assets { + get => $this->assets ?? $this->make(name: 'assets.group.default'); + } + + public private(set) Mailer $mailer { + get => $this->mailer ?? $this->make(name: Mailer::class); + } + + public private(set) PhpSession $session { + get => $this->session ?? $this->make(name: PhpSession::class); + } + + public private(set) Flash $flash { + get => $this->flash ?? $this->make(name: Flash::class); + } + + public private(set) EventDispatcherInterface $event { + get => $this->event ?? $this->make(name: EventDispatcherInterface::class); + } + + public private(set) HttpCookieFactory $httpCookie { + get => $this->httpCookie ?? $this->make(name: HttpCookieFactory::class); + } + + public private(set) LocalStorage $localStorage { + get => $this->localStorage ?? $this->make(name: LocalStorage::class); + } + + public private(set) ConfigContainer $configContainer { + get => $this->configContainer ?? $this->make(name: ConfigContainer::class); + } + + public private(set) PipelineBuilder $pipeline { + get => $this->pipeline ?? $this->make(name: PipelineBuilder::class); + } + + public private(set) Observer $hook { + get => $this->hook ?? $this->make(name: Observer::class); + } + + public private(set) StringHelper $string { + get => $this->string ?? $this->make(name: StringHelper::class); + } + + public private(set) ArrayHelper $array { + get => $this->array ?? $this->make(name: ArrayHelper::class); + } + + public private(set) Router $router { + get => $this->router ?? $this->make(name: Router::class); + } + + public private(set) Paths $path { + get => $this->path ?? $this->make(name: Paths::class); + } + //phpcs:disable } diff --git a/Auth/Auth.php b/src/Auth/Auth.php similarity index 79% rename from Auth/Auth.php rename to src/Auth/Auth.php index 0e89211..1e560e9 100644 --- a/Auth/Auth.php +++ b/src/Auth/Auth.php @@ -11,6 +11,8 @@ use Psr\Http\Message\ServerRequestInterface; use Qubus\Config\ConfigContainer; use Qubus\Exception\Exception; +use Qubus\Http\Factories\JsonResponseFactory; +use Qubus\Http\Factories\RedirectResponseFactory; use Qubus\Http\Session\SessionEntity; class Auth implements Sentinel @@ -49,18 +51,15 @@ public function authenticate(ServerRequestInterface $request): ?SessionEntity } /** - * @throws Exception + * @throws \Exception */ public function unauthorized(ServerRequestInterface $request): ResponseInterface { - return $this->responseFactory - ->createResponse(code: 302) - ->withHeader( - name: 'Location', - value: $this->configContainer->getConfigKey( - key: 'auth.http_redirect', - default: $this->configContainer->getConfigKey(key: 'auth.login_url') - ) - ); + $location = $this->configContainer->getConfigKey(key: 'auth.http_redirect'); + if (!empty($location)) { + return RedirectResponseFactory::create($location); + } + + return JsonResponseFactory::create(data: 'Forbidden.', status: 403); } } diff --git a/Auth/Rbac/Entity/AssertionRule.php b/src/Auth/Rbac/Entity/AssertionRule.php similarity index 100% rename from Auth/Rbac/Entity/AssertionRule.php rename to src/Auth/Rbac/Entity/AssertionRule.php diff --git a/Auth/Rbac/Entity/Permission.php b/src/Auth/Rbac/Entity/Permission.php similarity index 75% rename from Auth/Rbac/Entity/Permission.php rename to src/Auth/Rbac/Entity/Permission.php index 5fd6638..7efcda7 100644 --- a/Auth/Rbac/Entity/Permission.php +++ b/src/Auth/Rbac/Entity/Permission.php @@ -6,15 +6,11 @@ interface Permission { - /** - * @return string - */ - public function getName(): string; + //phpcs:disable + public string $name { get; } - /** - * @return string - */ - public function getDescription(): string; + public string $description { get; } + //phpcs:enable /** * @param Permission $permission @@ -45,5 +41,5 @@ public function getRuleClass(): ?string; * @param array|null $params * @return bool */ - public function checkAccess(array $params = null): bool; + public function checkAccess(?array $params = null): bool; } diff --git a/Auth/Rbac/Entity/RbacPermission.php b/src/Auth/Rbac/Entity/RbacPermission.php similarity index 84% rename from Auth/Rbac/Entity/RbacPermission.php rename to src/Auth/Rbac/Entity/RbacPermission.php index d33704f..5638e24 100644 --- a/Auth/Rbac/Entity/RbacPermission.php +++ b/src/Auth/Rbac/Entity/RbacPermission.php @@ -16,40 +16,30 @@ class RbacPermission implements Permission protected array $childrenNames = []; protected ?string $ruleClass = ''; + //phpcs:disable /** - * @param string $permissionName + * @param string $name * @param string $description * @param StorageResource $rbacStorageCollection */ public function __construct( - protected string $permissionName, - protected string $description, + public private(set) string $name { + get => $this->name; + }, + public private(set) string $description { + get => $this->description; + }, protected StorageResource $rbacStorageCollection ) { } - - /** - * @return string - */ - public function getName(): string - { - return $this->permissionName; - } - - /** - * @return string - */ - public function getDescription(): string - { - return $this->description; - } + //phpcs.enable /** * @param Permission $permission */ public function addChild(Permission $permission): void { - $this->childrenNames[$permission->getName()] = true; + $this->childrenNames[$permission->name] = true; } /** diff --git a/Auth/Rbac/Entity/RbacRole.php b/src/Auth/Rbac/Entity/RbacRole.php similarity index 85% rename from Auth/Rbac/Entity/RbacRole.php rename to src/Auth/Rbac/Entity/RbacRole.php index 86f48af..9c24972 100644 --- a/Auth/Rbac/Entity/RbacRole.php +++ b/src/Auth/Rbac/Entity/RbacRole.php @@ -12,43 +12,32 @@ class RbacRole implements Role { protected array $childrenNames = []; - protected array $permissionNames = []; + //phpcs:disable /** - * @param string $roleName + * @param string $name * @param string $description * @param StorageResource $rbacStorageCollection */ public function __construct( - protected string $roleName, - protected string $description, + public private(set) string $name { + get => $this->name; + }, + public private(set) string $description { + get => $this->description; + }, protected StorageResource $rbacStorageCollection ) { } - - /** - * @return string - */ - public function getName(): string - { - return $this->roleName; - } - - /** - * @return string - */ - public function getDescription(): string - { - return $this->description; - } + //phpcs:enable /** * @param Role $role */ public function addChild(Role $role): void { - $this->childrenNames[$role->getName()] = true; + $this->childrenNames[$role->name] = true; } /** @@ -77,7 +66,7 @@ public function getChildren(): array */ public function addPermission(Permission $permission): void { - $this->permissionNames[$permission->getName()] = true; + $this->permissionNames[$permission->name] = true; } /** @@ -137,7 +126,7 @@ public function checkAccess(string $permissionName, ?array $params = null): bool protected function collectChildrenPermissions(Permission $permission, &$result): void { foreach ($permission->getChildren() as $childPermission) { - $childPermissionName = $childPermission->getName(); + $childPermissionName = $childPermission->name; if (!isset($result[$childPermissionName])) { $result[$childPermissionName] = $childPermission; $this->collectChildrenPermissions($childPermission, $result); diff --git a/Auth/Rbac/Entity/Role.php b/src/Auth/Rbac/Entity/Role.php similarity index 79% rename from Auth/Rbac/Entity/Role.php rename to src/Auth/Rbac/Entity/Role.php index ef0685e..da53754 100644 --- a/Auth/Rbac/Entity/Role.php +++ b/src/Auth/Rbac/Entity/Role.php @@ -8,15 +8,11 @@ interface Role { - /** - * @return string - */ - public function getName(): string; + //phpcs:disable + public string $name { get; } - /** - * @return string - */ - public function getDescription(): string; + public string $description { get; } + //phpcs:enable /** * @param Role $role @@ -55,5 +51,5 @@ public function getPermissions(bool $withChildren = false): array; * @return bool * @throws SentinelException */ - public function checkAccess(string $permissionName, array $params = null): bool; + public function checkAccess(string $permissionName, ?array $params = null): bool; } diff --git a/Auth/Rbac/Exception/SentinelException.php b/src/Auth/Rbac/Exception/SentinelException.php similarity index 100% rename from Auth/Rbac/Exception/SentinelException.php rename to src/Auth/Rbac/Exception/SentinelException.php diff --git a/Auth/Rbac/Guard.php b/src/Auth/Rbac/Guard.php similarity index 85% rename from Auth/Rbac/Guard.php rename to src/Auth/Rbac/Guard.php index bbfc20c..826aeaa 100644 --- a/Auth/Rbac/Guard.php +++ b/src/Auth/Rbac/Guard.php @@ -20,13 +20,17 @@ public function addRole(string $name, string $description = ''): Role; */ public function addPermission(string $name, string $description = ''): Permission; - public function getRoles(): array; + //phpcs:disable + public array $roles { &get; } + //phpcs:enable public function getRole(string $name): Role|null; public function deleteRole(string $name): void; - public function getPermissions(): array; + //phpcs:disable + public array $permissions { &get; } + //phpcs:enable public function getPermission(string $name): Permission|null; diff --git a/Auth/Rbac/Rbac.php b/src/Auth/Rbac/Rbac.php similarity index 90% rename from Auth/Rbac/Rbac.php rename to src/Auth/Rbac/Rbac.php index ff1a96e..69cdbd8 100644 --- a/Auth/Rbac/Rbac.php +++ b/src/Auth/Rbac/Rbac.php @@ -34,13 +34,14 @@ public function addPermission(string $name, string $description = ''): Permissio return $this->storageResource->addPermission($name, $description); } + //phpcs:disable /** * @return Role[] */ - public function getRoles(): array - { - return $this->storageResource->getRoles(); + public array $roles = [] { + &get => $this->roles; } + //phpcs:enable /** * @param string $name @@ -59,13 +60,14 @@ public function deleteRole(string $name): void $this->storageResource->deleteRole($name); } + //phpcs:disable /** * @return Permission[] */ - public function getPermissions(): array - { - return $this->storageResource->getPermissions(); + public array $permissions = [] { + &get => $this->permissions; } + //phpcs:enable /** * @param string $name diff --git a/src/Auth/Rbac/RbacLoader.php b/src/Auth/Rbac/RbacLoader.php new file mode 100644 index 0000000..10a8801 --- /dev/null +++ b/src/Auth/Rbac/RbacLoader.php @@ -0,0 +1,82 @@ +configContainer->getConfigKey(key: 'rbac.roles'))) { + $rolesConfig = (array) $this->configContainer->getConfigKey(key: 'rbac.roles', default: []); + $this->addRoles($rolesConfig); + } + } + + /** + * @throws Exception + */ + public function initRbacPermissions(): void + { + if (!is_null__($this->configContainer->getConfigKey(key: 'rbac.permissions'))) { + $permissionsConfig = (array) $this->configContainer->getConfigKey(key: 'rbac.permissions', default: []); + $this->addPermissions($permissionsConfig); + } + } + + private function addRoles(array $rolesConfig, ?Role $parent = null): void + { + foreach ($rolesConfig as $name => $config) { + $role = $this->rbac->addRole(name: $name, description: $config['description'] ?? ''); + if (!empty($config['permissions'])) { + foreach ((array) $config['permissions'] as $permissionName) { + if ($permission = $this->rbac->getPermission(name: $permissionName)) { + $role->addPermission($permission); + } else { + throw new RuntimeException(message: sprintf('Permission not found: %s', $permissionName)); + } + } + } + } + + $parent?->addChild($role); + + if (!empty($config['roles'])) { + $this->addRoles(rolesConfig: $config['roles'], parent: $role); + } + } + + /** + * @throws SentinelException + */ + private function addPermissions(array $permissionsConfig, ?Permission $parent = null): void + { + foreach ($permissionsConfig as $name => $config) { + $permission = $this->rbac->addPermission(name: $name, description: $config['description'] ?? ''); + + $parent?->addChild($permission); + + if (!empty($config['permissions'])) { + $this->addPermissions(permissionsConfig: $config['permissions'], parent: $permission); + } + } + } +} diff --git a/Auth/Rbac/Resource/BaseStorageResource.php b/src/Auth/Rbac/Resource/BaseStorageResource.php similarity index 78% rename from Auth/Rbac/Resource/BaseStorageResource.php rename to src/Auth/Rbac/Resource/BaseStorageResource.php index 095d1a0..2531027 100644 --- a/Auth/Rbac/Resource/BaseStorageResource.php +++ b/src/Auth/Rbac/Resource/BaseStorageResource.php @@ -12,9 +12,15 @@ abstract class BaseStorageResource implements StorageResource { - protected array $roles = []; + //phpcs:disable + public protected(set) array $roles = [] { + &get => $this->roles; + } - protected array $permissions = []; + public protected(set) array $permissions = [] { + &get => $this->permissions; + } + //phpcs:enable /** * @throws SentinelException @@ -24,7 +30,7 @@ public function addRole(string $name, string $description = ''): Role if (isset($this->roles[$name])) { throw new SentinelException(message: 'Role already exists.'); } - $role = new RbacRole(roleName: $name, description: $description, rbacStorageCollection: $this); + $role = new RbacRole(name: $name, description: $description, rbacStorageCollection: $this); $this->roles[$name] = $role; return $role; } @@ -36,7 +42,7 @@ public function addPermission(string $name, string $description = ''): Permissio } $permission = new RbacPermission( - permissionName: $name, + name: $name, description: $description, rbacStorageCollection: $this ); @@ -45,11 +51,6 @@ public function addPermission(string $name, string $description = ''): Permissio return $permission; } - public function getRoles(): array - { - return $this->roles; - } - public function getRole(string $name): ?Role { return $this->roles[$name] ?? null; @@ -59,16 +60,11 @@ public function deleteRole(string $name): void { unset($this->roles[$name]); - foreach ($this->getRoles() as $role) { + foreach ($this->roles as $role) { $role->removeChild($name); } } - public function getPermissions(): array - { - return $this->permissions; - } - public function getPermission(string $name): ?Permission { return $this->permissions[$name] ?? null; @@ -78,11 +74,11 @@ public function deletePermission(string $name): void { unset($this->permissions[$name]); - foreach ($this->getRoles() as $role) { + foreach ($this->roles as $role) { $role->removePermission(permissionName: $name); } - foreach ($this->getPermissions() as $permission) { + foreach ($this->permissions as $permission) { $permission->removeChild(permissionName: $name); } } diff --git a/src/Auth/Rbac/Resource/FileResource.php b/src/Auth/Rbac/Resource/FileResource.php new file mode 100644 index 0000000..edfe93c --- /dev/null +++ b/src/Auth/Rbac/Resource/FileResource.php @@ -0,0 +1,151 @@ +file = $file; + } + + /** + * @throws SentinelException + * @throws FilesystemException|TypeException + */ + public function load(): void + { + $this->clear(); + + if (!file_exists($this->file) || (!$data = LocalStorage::disk()->read(json_decode($this->file, true)))) { + $data = []; + } + + $this->restorePermissions($data['permissions'] ?? []); + $this->restoreRoles($data['roles'] ?? []); + } + + /** + * @throws FilesystemException + * @throws TypeException|FilesystemException + */ + public function save(): void + { + $data = [ + 'roles' => [], + 'permissions' => [], + ]; + foreach ($this->roles as $role) { + $data['roles'][$role->name] = $this->roleToRow($role); + } + foreach ($this->permissions as $permission) { + $data['permissions'][$permission->name] = $this->permissionToRow($permission); + } + + LocalStorage::disk()->write($this->file, json_encode(value: $data, flags: JSON_PRETTY_PRINT)); + } + + protected function roleToRow(Role $role): array + { + $result = []; + $result['name'] = $role->name; + $result['description'] = $role->description; + $childrenNames = []; + foreach ($role->getChildren() as $child) { + $childrenNames[] = $child->name; + } + $result['children'] = $childrenNames; + $permissionNames = []; + foreach ($role->getPermissions() as $permission) { + $permissionNames[] = $permission->name; + } + $result['permissions'] = $permissionNames; + return $result; + } + + protected function permissionToRow(Permission $permission): array + { + $result = []; + $result['name'] = $permission->name; + $result['description'] = $permission->description; + $childrenNames = []; + foreach ($permission->getChildren() as $child) { + $childrenNames[] = $child->name; + } + $result['children'] = $childrenNames; + $result['ruleClass'] = $permission->getRuleClass(); + return $result; + } + + /** + * @throws SentinelException + */ + protected function restorePermissions(array $permissionsData): void + { + /** @var string[][] $permChildrenNames */ + $permChildrenNames = []; + + foreach ($permissionsData as $pData) { + $permission = $this->addPermission($pData['name'] ?? '', $pData['description'] ?? ''); + $permission->setRuleClass($pData['ruleClass'] ?? ''); + $permChildrenNames[$permission->name] = $pData['children'] ?? []; + } + + foreach ($permChildrenNames as $permissionName => $childrenNames) { + foreach ($childrenNames as $childName) { + $permission = $this->getPermission($permissionName); + $child = $this->getPermission($childName); + if ($permission && $child) { + $permission->addChild($child); + } + } + } + } + + /** + * @throws SentinelException + */ + protected function restoreRoles($rolesData): void + { + /** @var string[][] $rolesChildrenNames */ + $rolesChildrenNames = []; + + foreach ($rolesData as $rData) { + $role = $this->addRole($rData['name'] ?? '', $rData['description'] ?? ''); + $rolesChildrenNames[$role->name] = $rData['children'] ?? []; + $permissionNames = $rData['permissions'] ?? []; + foreach ($permissionNames as $permissionName) { + if ($permission = $this->getPermission($permissionName)) { + $role->addPermission($permission); + } + } + } + + foreach ($rolesChildrenNames as $roleName => $childrenNames) { + foreach ($childrenNames as $childName) { + $role = $this->getRole($roleName); + $child = $this->getRole($childName); + if ($role && $child) { + $role->addChild($child); + } + } + } + } +} diff --git a/Auth/Rbac/Resource/StorageResource.php b/src/Auth/Rbac/Resource/StorageResource.php similarity index 100% rename from Auth/Rbac/Resource/StorageResource.php rename to src/Auth/Rbac/Resource/StorageResource.php diff --git a/Auth/Repository/AuthUserRepository.php b/src/Auth/Repository/AuthUserRepository.php similarity index 73% rename from Auth/Repository/AuthUserRepository.php rename to src/Auth/Repository/AuthUserRepository.php index a28e0b4..98ad99b 100644 --- a/Auth/Repository/AuthUserRepository.php +++ b/src/Auth/Repository/AuthUserRepository.php @@ -5,6 +5,7 @@ namespace Codefy\Framework\Auth\Repository; use Qubus\Http\Session\SessionEntity; +use SensitiveParameter; interface AuthUserRepository { @@ -17,5 +18,5 @@ interface AuthUserRepository * @param string|null $password * @return SessionEntity|null */ - public function authenticate(string $credential, ?string $password = null): ?SessionEntity; + public function authenticate(string $credential, #[SensitiveParameter] ?string $password = null): ?SessionEntity; } diff --git a/Auth/Repository/PdoRepository.php b/src/Auth/Repository/PdoRepository.php similarity index 51% rename from Auth/Repository/PdoRepository.php rename to src/Auth/Repository/PdoRepository.php index ba69258..15a5ccb 100644 --- a/Auth/Repository/PdoRepository.php +++ b/src/Auth/Repository/PdoRepository.php @@ -4,17 +4,19 @@ namespace Codefy\Framework\Auth\Repository; +use Codefy\Framework\Auth\UserSession; use Codefy\Framework\Support\Password; -use PDO; use Qubus\Config\ConfigContainer; use Qubus\Exception\Exception; +use Qubus\Expressive\Connection; use Qubus\Http\Session\SessionEntity; +use SensitiveParameter; use function sprintf; class PdoRepository implements AuthUserRepository { - public function __construct(private PDO $pdo, protected ConfigContainer $config) + public function __construct(private Connection $connection, protected ConfigContainer $config) { } @@ -22,7 +24,7 @@ public function __construct(private PDO $pdo, protected ConfigContainer $config) * @inheritdoc * @throws Exception */ - public function authenticate(string $credential, #[\SensitiveParameter] ?string $password = null): ?SessionEntity + public function authenticate(string $credential, #[SensitiveParameter] ?string $password = null): ?SessionEntity { $fields = $this->config->getConfigKey(key: 'auth.pdo.fields'); @@ -32,7 +34,7 @@ public function authenticate(string $credential, #[\SensitiveParameter] ?string $fields['identity'] ); - $stmt = $this->pdo->prepare(query: $sql); + $stmt = $this->connection->pdo->prepare($sql); if (false === $stmt) { return null; } @@ -48,31 +50,9 @@ public function authenticate(string $credential, #[\SensitiveParameter] ?string $passwordHash = (string) ($result->{$fields['password']} ?? ''); if (Password::verify(password: $password ?? '', hash: $passwordHash)) { - $user = new class () implements SessionEntity { - public ?string $token = null; - public ?string $role = null; - - public function withToken(?string $token = null): self - { - $this->token = $token; - return $this; - } - - public function withRole(?string $role = null): self - { - $this->role = $role; - return $this; - } - - public function isEmpty(): bool - { - return !empty($this->token) && !empty($this->role); - } - }; - + $user = new UserSession(); $user - ->withToken($result->token) - ->withRole($result->role); + ->withToken($result->token); return $user; } diff --git a/Auth/Sentinel.php b/src/Auth/Sentinel.php similarity index 100% rename from Auth/Sentinel.php rename to src/Auth/Sentinel.php diff --git a/Auth/Traits/BadPropertyCallException.php b/src/Auth/Traits/BadPropertyCallException.php similarity index 100% rename from Auth/Traits/BadPropertyCallException.php rename to src/Auth/Traits/BadPropertyCallException.php diff --git a/Auth/Traits/ImmutableAware.php b/src/Auth/Traits/ImmutableAware.php similarity index 100% rename from Auth/Traits/ImmutableAware.php rename to src/Auth/Traits/ImmutableAware.php diff --git a/Auth/UserSession.php b/src/Auth/UserSession.php similarity index 58% rename from Auth/UserSession.php rename to src/Auth/UserSession.php index afbf314..3c94c8e 100644 --- a/Auth/UserSession.php +++ b/src/Auth/UserSession.php @@ -8,19 +8,12 @@ class UserSession implements SessionEntity { - public ?string $token = null; - - public ?string $role = null; + public protected(set) ?string $token = null; public function withToken(?string $token = null): self { $this->token = $token; - return $this; - } - public function withRole(?string $role = null): self - { - $this->role = $role; return $this; } @@ -29,14 +22,10 @@ public function clear(): void if (!empty($this->token)) { unset($this->token); } - - if (!empty($this->role)) { - unset($this->role); - } } public function isEmpty(): bool { - return empty($this->token) && empty($this->role); + return empty($this->token); } } diff --git a/Bootstrap/BootProviders.php b/src/Bootstrap/BootProviders.php similarity index 100% rename from Bootstrap/BootProviders.php rename to src/Bootstrap/BootProviders.php diff --git a/src/Bootstrap/RegisterProviders.php b/src/Bootstrap/RegisterProviders.php new file mode 100644 index 0000000..becfb40 --- /dev/null +++ b/src/Bootstrap/RegisterProviders.php @@ -0,0 +1,109 @@ +mergeAdditionalProviders(app: $app); + $app->registerConfiguredServiceProviders(); + } + + /** + * Merge additional configured providers into the configuration. + * + * @param Application $app + * @return void + * @throws Exception + */ + protected function mergeAdditionalProviders(Application $app): void + { + if ( + self::$bootstrapProviderPath && + file_exists(self::$bootstrapProviderPath) + ) { + $packageProviders = require self::$bootstrapProviderPath; + + foreach ($packageProviders as $index => $provider) { + if (! class_exists($provider)) { + unset($packageProviders[$index]); + } + } + } + + /** @var ConfigContainer $config */ + $config = $app->make(name: 'codefy.config'); + + $arrayMerge = array_unique( + array: array_merge( + self::$merge, + $config->getConfigKey( + key: 'app.providers', + default: CodefyServiceProvider::defaultProviders()->toArray() + ), + array_values($packageProviders ?? []), + ) + ); + + self::$providers = array_merge(self::$providers, $arrayMerge); + + $config->setConfigKey(key: 'app', value: ['providers' => $arrayMerge,]); + } + + /** + * Merge the given providers into the provider configuration + * before registration. + * + * @param array $providers + * @param string|null $bootstrapProviderPath + * @return void + */ + public static function merge(array $providers, ?string $bootstrapProviderPath = null): void + { + self::$bootstrapProviderPath = $bootstrapProviderPath; + + self::$merge = array_values( + array: array_filter( + array: array_unique( + array: array_merge(self::$merge, $providers) + ) + ) + ); + } + + public static function flushState(): void + { + self::$bootstrapProviderPath = null; + + self::$merge = []; + } +} diff --git a/src/Configuration/ApplicationBuilder.php b/src/Configuration/ApplicationBuilder.php new file mode 100644 index 0000000..e45f726 --- /dev/null +++ b/src/Configuration/ApplicationBuilder.php @@ -0,0 +1,237 @@ +app->singleton( + \Codefy\Framework\Contracts\Http\Kernel::class, + fn() => $this->app->make(name: \Codefy\Framework\Http\Kernel::class) + ); + + $this->app->singleton( + key: \Codefy\Framework\Contracts\Console\Kernel::class, + value: fn() => $this->app->make(name: \Codefy\Framework\Console\ConsoleKernel::class) + ); + + return $this; + } + + /** + * Register additional service providers. + * + * @param array $providers + * @param bool $withBootstrapProviders + * @return $this + * @throws TypeException + */ + public function withProviders(array $providers = [], bool $withBootstrapProviders = true): self + { + RegisterProviders::merge( + providers: $providers, + bootstrapProviderPath: $withBootstrapProviders + ? $this->app->getBootstrapProvidersPath() + : null + ); + + foreach ($providers as $provider) { + $this->app->registerServiceProvider($provider); + } + + return $this; + } + + /** + * Register an array of singletons that are resource intensive + * or are not called often. + * + * @param array $singletons + * @return $this + */ + public function withSingletons(array $singletons = []): self + { + if (empty($singletons)) { + return $this; + } + + foreach ($singletons as $key => $callable) { + $this->app->singleton($key, $callable); + }; + + return $this; + } + + /** + * Register the routing services for the application. + * + * @param callable|Closure|null $using + * @param array|string|null $web + * @param array|null $class + * @param array|string|null $api + * @param string $apiPrefix + * @param callable|null $then + * @return $this + * @throws TypeException + */ + public function withRouting( + callable|Closure|null $using = null, + array|string|null $web = null, + ?array $class = null, + array|string|null $api = null, + string $apiPrefix = 'api', + ?callable $then = null, + ): self { + if ( + is_null__($using) && + (is_string($web) || is_array($web) || is_string($api) || is_array($api)) || + is_callable($then) + ) { + $using = $this->buildRoutingCallback($web, $api, $apiPrefix, $then); + } + + RoutingServiceProvider::loadRoutesUsing($using); + + $this->app->booting(function () { + $this->app->registerServiceProvider(serviceProvider: RoutingServiceProvider::class, force: true); + }); + + // Class-based routes + if (!is_null__($class) && is_array($class)) { + foreach ($class as $route) { + $this->app->execute([$route, 'handle']); + } + } + + return $this; + } + + /** + * Set whether environment variables + * should be encrypted. + * + * @param bool $bool Default: false. + * @return $this + */ + public function withEncryptedEnv(bool $bool = false): self + { + $this->app::$encryptedEnv = $bool; + + return $this; + } + + /** + * Create the routing callback for the application. + * + * @param array|string|null $web + * @param array|string|null $api + * @param string $apiPrefix + * @param callable|null $then + * @return Closure + */ + protected function buildRoutingCallback( + array|string|null $web = null, + array|string|null $api = null, + string $apiPrefix = 'api', + ?callable $then = null + ): Closure { + return function (Router $router) use ($web, $api, $apiPrefix, $then) { + $registrar = new RoutingRegistrar($router); + + // Web routes + if ($web) { + $registrar->group($web); + } + + // API routes + if ($api) { + /** + * API middleware filter. + */ + $apiMiddleware = __observer()->filter->applyFilter('rest.api', ['api']); + $registrar->group($api, middleware: $apiMiddleware, prefix: $apiPrefix); + } + + // Final callback + if (is_callable($then)) { + $then($router); + } + }; + } + + /** + * Register a callback to be invoked when the application's + * service providers are registered. + * + * @param callable $callback + * @return $this + */ + public function registered(callable $callback): self + { + $this->app->registered(callback: $callback); + + return $this; + } + + /** + * Register a callback to be invoked when the application is "booting". + * + * @param callable $callback + * @return $this + */ + public function booting(callable $callback): self + { + $this->app->booting($callback); + + return $this; + } + + /** + * Register a callback to be invoked when the application is "booted". + * + * @param callable $callback + * @return $this + */ + public function booted(callable $callback): self + { + $this->app->booted($callback); + + return $this; + } + + /** + * Return the application instance. + * + * @return Application + */ + public function return(): Application + { + return $this->app; + } +} diff --git a/src/Console/Commands/EncryptEnvCommand.php b/src/Console/Commands/EncryptEnvCommand.php new file mode 100644 index 0000000..abd4c32 --- /dev/null +++ b/src/Console/Commands/EncryptEnvCommand.php @@ -0,0 +1,69 @@ +addOption( + name: 'env', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Set the environment file to encrypt.' + ); + } + + public function handle(): int + { + $this->terminalRaw(string: 'Encrypting data and creating file . . .'); + + try { + $savedKeyString = file_get_contents(filename: base_path(path: '.enc.key')); + $key = Key::loadFromAsciiSafeString(saved_key_string: $savedKeyString); + + if ($this->getOptions('env')) { + $file = base_path(path: sprintf('.env.%s', $this->getOptions('env'))); + } else { + $file = base_path(path: '.env'); + } + + if (!file_exists($file)) { + $this->output->writeln(sprintf('File %s does not exist.', $file)); + return ConsoleCommand::FAILURE; + } + + File::encrypt($file, base_path(path: '.env.enc'), $key); + } catch (BadFormatException | EnvironmentIsBrokenException $e) { + return ConsoleCommand::FAILURE; + } + + $this->terminalRaw(string: '.env.enc file created.'); + + // return value is important when using CI + // to fail the build when the command fails + // 0 = success, other values = fail + return ConsoleCommand::SUCCESS; + } +} diff --git a/src/Console/Commands/FlushPipelineCommand.php b/src/Console/Commands/FlushPipelineCommand.php new file mode 100644 index 0000000..a8a785f --- /dev/null +++ b/src/Console/Commands/FlushPipelineCommand.php @@ -0,0 +1,169 @@ +addOption( + name: 'group', + shortcut: '-g', + mode: InputOption::VALUE_REQUIRED, + description: 'Only flush the provided group' + ) + ->addOption( + name: 'force', + shortcut: '-f', + mode: InputOption::VALUE_NONE, + description: 'Do not prompt for confirmation' + ) + ->setDescription(description: 'Flush assets pipeline..') + ->setHelp( + help: <<asset:flush flushes the assets pipeline. +php codex asset:flush +EOT + ); + } + + /** + * @throws Exception + * @throws FilesystemException + */ + public function handle(): int + { + // Get directories to purge + if (! $directories = $this->getPipelineDirectories()) { + $this->terminalError(string: 'The provided group does not exist'); + + return ConsoleCommand::FAILURE; + } + + // Ask for confirmation + if (! $this->getOptions(key: 'force')) { + $this->terminalInfo(string: 'All content of the following directories will be deleted:'); + + foreach ($directories as $dir) { + $this->terminalComment($dir); + } + + if (! $this->confirm(question: 'Do you want to continue?')) { + return ConsoleCommand::SUCCESS; + } + } + + // Purge directories + $this->terminalComment(string: 'Flushing pipeline directories...'); + + foreach ($directories as $dir) { + $this->output->write(messages: "$dir "); + + if ($this->purgeDir($dir)) { + $this->terminalInfo(string: 'OK'); + } else { + $this->terminalError(string: 'ERROR'); + } + } + + $this->terminalComment(string: 'Done!'); + + return ConsoleCommand::SUCCESS; + } + + /** + * Get the pipeline directories of the groups. + * + * @return array + * @throws Exception + */ + protected function getPipelineDirectories(): array + { + // Parse configured groups + $config = $this->codefy->configContainer->getConfigKey(key: 'assets'); + + $groups = (isset($config['default'])) ? $config : ['default' => $config]; + + if (! empty($group = $this->getOptions(key: 'group'))) { + $groups = $this->codefy->array->subset(array: $groups, keys: [$group]); + } + + // Parse pipeline directories of each group + $directories = []; + + foreach ($groups as $group => $config) { + $pipelineDir = (isset($config['pipeline_dir'])) ? $config['pipeline_dir'] : 'min'; + $publicDir = (isset($config['public_dir'])) ? $config['public_dir'] : public_path(); + $publicDir = rtrim($publicDir, $this->codefy::DS); + + $cssDir = (isset($config['css_dir'])) ? $config['css_dir'] : 'css'; + $directories[] = implode($this->codefy::DS, [$publicDir, $cssDir, $pipelineDir]); + + $jsDir = (isset($config['js_dir'])) ? $config['js_dir'] : 'js'; + $directories[] = implode($this->codefy::DS, [$publicDir, $jsDir, $pipelineDir]); + } + + // Clean results + $directories = array_unique($directories); + sort($directories); + + return $directories; + } + + /** + * Remove the contents of a given directory. + * + * @param string $directory + * @return bool + * @throws FilesystemException + */ + protected function purgeDir(string $directory): bool + { + /** @var FileSystem $filesystem */ + $filesystem = $this->codefy->make(name: 'filesystem.default'); + + if (! $filesystem->directoryExists($directory)) { + return true; + } + + if (\Qubus\Support\Helpers\is_writable($directory)) { + $contents = $filesystem->listContents(location: $directory, deep: true)->toArray(); + + foreach ($contents as $item) { + // Check if the item is a file or a directory + if ($item->isFile()) { + $filesystem->delete($item->path()); + } elseif ($item->isDir()) { + // Delete the directory and its contents (already handled by recursive listing) + // You might want to delete directories only after their contents are cleared + // Or, if you're deleting from the deepest level first, this will work. + // For a more controlled approach, you might want to sort the $contents + // to process deeper items first. + $filesystem->deleteDirectory($item->path()); + } + } + $filesystem->deleteDirectory($directory); + + return true; + } + + $this->terminalError(sprintf('Directory "%s" is not writable.', $directory)); + + return false; + } +} diff --git a/src/Console/Commands/GenerateEncryptionKeyCommand.php b/src/Console/Commands/GenerateEncryptionKeyCommand.php new file mode 100644 index 0000000..e91c2e0 --- /dev/null +++ b/src/Console/Commands/GenerateEncryptionKeyCommand.php @@ -0,0 +1,36 @@ +saveToAsciiSafeString(); + + $this->terminalRaw(string: sprintf( + 'Encryption Key: %s', + $key + )); + + // return value is important when using CI + // to fail the build when the command fails + // 0 = success, other values = fail + return ConsoleCommand::SUCCESS; + } +} diff --git a/src/Console/Commands/GenerateEncryptionKeyFileCommand.php b/src/Console/Commands/GenerateEncryptionKeyFileCommand.php new file mode 100644 index 0000000..6393ba8 --- /dev/null +++ b/src/Console/Commands/GenerateEncryptionKeyFileCommand.php @@ -0,0 +1,40 @@ +terminalRaw(string: 'Generating encryption key . . .'); + + $key = Key::createNewRandomKey()->saveToAsciiSafeString(); + + $this->terminalRaw(string: 'Generating encryption key file . . .'); + + file_put_contents(base_path(path: '.enc.key'), $key); + + $this->terminalRaw(string: '.enc.key file created.'); + + // return value is important when using CI + // to fail the build when the command fails + // 0 = success, other values = fail + return ConsoleCommand::SUCCESS; + } +} diff --git a/Console/Commands/InitCommand.php b/src/Console/Commands/InitCommand.php similarity index 100% rename from Console/Commands/InitCommand.php rename to src/Console/Commands/InitCommand.php diff --git a/Console/Commands/MakeCommand.php b/src/Console/Commands/MakeCommand.php similarity index 91% rename from Console/Commands/MakeCommand.php rename to src/Console/Commands/MakeCommand.php index 5acddad..2184fda 100644 --- a/Console/Commands/MakeCommand.php +++ b/src/Console/Commands/MakeCommand.php @@ -15,7 +15,7 @@ class MakeCommand extends ConsoleCommand { use MakeCommandAware; - protected const FILE_EXTENSION = '.php'; + protected const string FILE_EXTENSION = '.php'; private array $errors = []; @@ -30,12 +30,14 @@ class MakeCommand extends ConsoleCommand protected string $help = 'Command which can generate a class file from a set of predefined stub files'; /* @var array Stubs */ - private const STUBS = [ + private const array STUBS = [ 'controller' => 'App\Infrastructure\Http\Controllers', 'repository' => 'App\Infrastructure\Persistence\Repository', 'provider' => 'App\Infrastructure\Providers', 'middleware' => 'App\Infrastructure\Http\Middleware', 'error' => 'App\Infrastructure\Errors', + 'command' => 'App\Application\Console\Commands', + 'route' => 'App\Infrastructure\Http\Routes', ]; protected array $args = [ @@ -67,7 +69,6 @@ public function handle(): int return ConsoleCommand::SUCCESS; } catch (MakeCommandFileAlreadyExistsException | TypeException | RuntimeException | FilesystemException $e) { $this->terminalError(string: sprintf('%s', $e->getMessage())); - } finally { return ConsoleCommand::FAILURE; } } diff --git a/Console/Commands/CheckCommand.php b/src/Console/Commands/MigrateCheckCommand.php similarity index 97% rename from Console/Commands/CheckCommand.php rename to src/Console/Commands/MigrateCheckCommand.php index 2693101..decef05 100644 --- a/Console/Commands/CheckCommand.php +++ b/src/Console/Commands/MigrateCheckCommand.php @@ -7,7 +7,7 @@ use Qubus\Exception\Exception; use Symfony\Component\Console\Helper\Table; -class CheckCommand extends PhpMigCommand +class MigrateCheckCommand extends PhpMigCommand { protected string $name = 'migrate:check'; diff --git a/Console/Commands/MigrateCommand.php b/src/Console/Commands/MigrateCommand.php similarity index 100% rename from Console/Commands/MigrateCommand.php rename to src/Console/Commands/MigrateCommand.php diff --git a/Console/Commands/DownCommand.php b/src/Console/Commands/MigrateDownCommand.php similarity index 97% rename from Console/Commands/DownCommand.php rename to src/Console/Commands/MigrateDownCommand.php index 8208416..df67b85 100644 --- a/Console/Commands/DownCommand.php +++ b/src/Console/Commands/MigrateDownCommand.php @@ -8,7 +8,7 @@ use Qubus\Exception\Exception; use Symfony\Component\Console\Input\InputArgument; -class DownCommand extends PhpMigCommand +class MigrateDownCommand extends PhpMigCommand { protected string $name = 'migrate:down'; diff --git a/Console/Commands/GenerateCommand.php b/src/Console/Commands/MigrateGenerateCommand.php similarity index 93% rename from Console/Commands/GenerateCommand.php rename to src/Console/Commands/MigrateGenerateCommand.php index 1a8efff..81d2dbd 100644 --- a/Console/Commands/GenerateCommand.php +++ b/src/Console/Commands/MigrateGenerateCommand.php @@ -11,9 +11,12 @@ use RuntimeException; use Symfony\Component\Console\Input\InputArgument; +use function getcwd; use function Qubus\Support\Helpers\is_writable; +use function sprintf; +use function str_replace; -class GenerateCommand extends PhpMigCommand +class MigrateGenerateCommand extends PhpMigCommand { protected string $name = 'migrate:generate'; @@ -122,7 +125,7 @@ public function handle(): int declare(strict_types=1); -use Codefy\Framework\Migration\Migration; +use Qubus\Expressive\Migration\Migration; class $className extends Migration { @@ -144,17 +147,13 @@ public function down(): void } if (false === file_put_contents(filename: $path, data: $contents)) { - throw new RuntimeException( - message: sprintf( - 'The file "%s" could not be written to', - $path - ) - ); + $this->terminalRaw(sprintf('The file "%s" could not be generated.', $path)); + return ConsoleCommand::FAILURE; } $this->terminalRaw( - string: '+f ' . - '.' . str_replace(search: getcwd(), replace: '', subject: $path) + string: '+f ' . + '.' . str_replace(search: getcwd(), replace: '', subject: $path) . '' ); return ConsoleCommand::SUCCESS; diff --git a/Console/Commands/RedoCommand.php b/src/Console/Commands/MigrateRedoCommand.php similarity index 97% rename from Console/Commands/RedoCommand.php rename to src/Console/Commands/MigrateRedoCommand.php index 6ac7fa9..dc4353e 100644 --- a/Console/Commands/RedoCommand.php +++ b/src/Console/Commands/MigrateRedoCommand.php @@ -8,7 +8,7 @@ use Qubus\Exception\Exception; use Symfony\Component\Console\Input\InputArgument; -class RedoCommand extends PhpMigCommand +class MigrateRedoCommand extends PhpMigCommand { protected string $name = 'migrate:redo'; diff --git a/Console/Commands/RollbackCommand.php b/src/Console/Commands/MigrateRollbackCommand.php similarity index 98% rename from Console/Commands/RollbackCommand.php rename to src/Console/Commands/MigrateRollbackCommand.php index 098755a..ff1d13e 100644 --- a/Console/Commands/RollbackCommand.php +++ b/src/Console/Commands/MigrateRollbackCommand.php @@ -8,7 +8,7 @@ use Qubus\Exception\Exception; use Symfony\Component\Console\Input\InputArgument; -class RollbackCommand extends PhpMigCommand +class MigrateRollbackCommand extends PhpMigCommand { protected string $name = 'migrate:rollback'; diff --git a/Console/Commands/StatusCommand.php b/src/Console/Commands/MigrateStatusCommand.php similarity index 97% rename from Console/Commands/StatusCommand.php rename to src/Console/Commands/MigrateStatusCommand.php index d8f77ff..e6f8fab 100644 --- a/Console/Commands/StatusCommand.php +++ b/src/Console/Commands/MigrateStatusCommand.php @@ -10,7 +10,7 @@ use function sprintf; -class StatusCommand extends PhpMigCommand +class MigrateStatusCommand extends PhpMigCommand { protected string $name = 'migrate:status'; diff --git a/Console/Commands/UpCommand.php b/src/Console/Commands/MigrateUpCommand.php similarity index 97% rename from Console/Commands/UpCommand.php rename to src/Console/Commands/MigrateUpCommand.php index 062b889..d497fbe 100644 --- a/Console/Commands/UpCommand.php +++ b/src/Console/Commands/MigrateUpCommand.php @@ -8,7 +8,7 @@ use Qubus\Exception\Exception; use Symfony\Component\Console\Input\InputArgument; -class UpCommand extends PhpMigCommand +class MigrateUpCommand extends PhpMigCommand { protected string $name = 'migrate:up'; diff --git a/Console/Commands/PasswordHashCommand.php b/src/Console/Commands/PasswordHashCommand.php similarity index 93% rename from Console/Commands/PasswordHashCommand.php rename to src/Console/Commands/PasswordHashCommand.php index c051902..a834db1 100644 --- a/Console/Commands/PasswordHashCommand.php +++ b/src/Console/Commands/PasswordHashCommand.php @@ -7,6 +7,7 @@ use Codefy\Framework\Application; use Codefy\Framework\Console\ConsoleCommand; use Codefy\Framework\Support\Password; +use Qubus\Exception\Exception; class PasswordHashCommand extends ConsoleCommand { @@ -23,6 +24,9 @@ public function __construct(protected Application $codefy) parent::__construct(codefy: $codefy); } + /** + * @throws Exception + */ public function handle(): int { $password = $this->getArgument(key: 'password'); diff --git a/Console/Commands/PhpMigCommand.php b/src/Console/Commands/PhpMigCommand.php similarity index 96% rename from Console/Commands/PhpMigCommand.php rename to src/Console/Commands/PhpMigCommand.php index 6f77ad1..b9e65d9 100644 --- a/Console/Commands/PhpMigCommand.php +++ b/src/Console/Commands/PhpMigCommand.php @@ -7,11 +7,11 @@ use ArrayAccess; use Codefy\Framework\Application; use Codefy\Framework\Console\ConsoleCommand; -use Codefy\Framework\Migration\Adapter\MigrationAdapter; -use Codefy\Framework\Migration\Migration; -use Codefy\Framework\Migration\Migrator; use Qubus\Exception\Data\TypeException; use Qubus\Exception\Exception; +use Qubus\Expressive\Migration\Adapter\MigrationAdapter; +use Qubus\Expressive\Migration\Migration; +use Qubus\Expressive\Migration\Migrator; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -135,7 +135,7 @@ protected function bootstrapAdapter(InputInterface $input): MigrationAdapter if (!($adapter instanceof MigrationAdapter)) { throw new RuntimeException( message: "phpmig.adapter or phpmig.sets must be an - instance of \\Codefy\\Framework\\Migration\\Adapter\\MigrationAdapter" + instance of \Qubus\Expressive\Migration\Adapter\MigrationAdapter" ); } @@ -226,7 +226,7 @@ protected function bootstrapMigrations(InputInterface $input, OutputInterface $o } $migrationName = preg_replace(pattern: '/^[0-9]+_/', replacement: '', subject: basename(path: $path)); - if (false !== strpos(haystack: $migrationName, needle: '.')) { + if (str_contains($migrationName, '.')) { $migrationName = substr( string: $migrationName, offset: 0, @@ -237,7 +237,7 @@ protected function bootstrapMigrations(InputInterface $input, OutputInterface $o $class = $this->migrationToClassName(migrationName: $migrationName); if ( - $this instanceof GenerateCommand + $this instanceof MigrateGenerateCommand && $class == $this->migrationToClassName(migrationName: $input->getArgument(name: 'name')) ) { throw new TypeException( @@ -276,7 +276,7 @@ protected function bootstrapMigrations(InputInterface $input, OutputInterface $o if (!($migration instanceof Migration)) { throw new TypeException( message: sprintf( - 'The class "%s" in file "%s" must extend \Codefy\Framework\Migration\Migration', + 'The class "%s" in file "%s" must extend \Qubus\Expressive\Migration\Migration', $class, $path ) @@ -299,9 +299,9 @@ protected function bootstrapMigrations(InputInterface $input, OutputInterface $o /** * @param OutputInterface $output - * @return mixed + * @return Migrator */ - protected function bootstrapMigrator(OutputInterface $output): mixed + protected function bootstrapMigrator(OutputInterface $output): Migrator { return new Migrator(adapter: $this->getAdapter(), objectmap: $this->getObjectMap(), output: $output); } diff --git a/src/Console/Commands/QueueListCommand.php b/src/Console/Commands/QueueListCommand.php new file mode 100644 index 0000000..acaf614 --- /dev/null +++ b/src/Console/Commands/QueueListCommand.php @@ -0,0 +1,83 @@ +queue:list command prints a table of available queues. +php codex queue:list +EOT; + + protected function configure(): void + { + parent::configure(); + + $this + ->addOption( + name: 'name', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'List the queued jobs by node.' + ); + } + + public function handle(): int + { + try { + $table = new Table($this->output); + $table->setHeaders([ + "Queue Name", + "Class", + "Expression", + "Next Execution", + ]); + + $path = "" !== $this->getOptions('name') ? $this->getOptions('name') : 'nodequeue'; + + $db = Node::open(database_path(path: $path)); + $queues = $db->all(); + + foreach ($queues as $queue) { + $object = new JsonSerializer()->unserialize($queue['object']); + + $nextRun = new CronExpression($object->schedule); + + $table->addRow([ + $queue['name'], + get_class($object), + $nextRun->getExpression(), + $nextRun->getNextRunDate()->format(format: 'd F Y h:i A'), + ]); + } + + $table->render(); + } catch (InvalidJsonException | ReflectionException | Exception $e) { + return ConsoleCommand::FAILURE; + } + + // return value is important when using CI + // to fail the build when the command fails + // 0 = success, other values = fail + return ConsoleCommand::SUCCESS; + } +} diff --git a/src/Console/Commands/QueueRunCommand.php b/src/Console/Commands/QueueRunCommand.php new file mode 100644 index 0000000..e12d77f --- /dev/null +++ b/src/Console/Commands/QueueRunCommand.php @@ -0,0 +1,63 @@ +queue:run command dispatches the queues that are due to run. +php codex queue:run +EOT; + + protected function configure(): void + { + parent::configure(); + + $this + ->addOption( + name: 'name', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Runs the queue based on given node.' + ); + } + + public function handle(): int + { + try { + $path = "" !== $this->getOptions('name') ? $this->getOptions('name') : 'nodequeue'; + + $db = Node::open(database_path(path: $path)); + $queues = $db->all(); + + foreach ($queues as $queue) { + $object = new JsonSerializer()->unserialize($queue['object']); + queue($object)->dispatch(); + } + } catch (InvalidJsonException | ReflectionException | TypeException $e) { + return ConsoleCommand::FAILURE; + } + + // return value is important when using CI + // to fail the build when the command fails + // 0 = success, other values = fail + return ConsoleCommand::SUCCESS; + } +} diff --git a/Console/Commands/ListCommand.php b/src/Console/Commands/ScheduleListCommand.php similarity index 87% rename from Console/Commands/ListCommand.php rename to src/Console/Commands/ScheduleListCommand.php index b8ec3e0..6575e76 100644 --- a/Console/Commands/ListCommand.php +++ b/src/Console/Commands/ScheduleListCommand.php @@ -12,13 +12,16 @@ use Exception; use Symfony\Component\Console\Helper\Table; -class ListCommand extends ConsoleCommand +class ScheduleListCommand extends ConsoleCommand { protected string $name = 'schedule:list'; protected string $description = 'Lists the existing jobs/tasks.'; - protected string $help = 'This command displays the list of registered jobs/tasks.'; + protected string $help = <<schedule:list command prints a table with jobs/tasks to run. +php codex schedule:list +EOT; public function __construct(protected Schedule $schedule, protected Application $codefy) { @@ -45,7 +48,7 @@ public function handle(): int $nextRun = new CronExpression($job->getExpression()); $fullCommand = $job->getCommand(); - if($job instanceof Callback) { + if ($job instanceof Callback) { $fullCommand = $job->__toString(); } diff --git a/Console/Commands/ScheduleRunCommand.php b/src/Console/Commands/ScheduleRunCommand.php similarity index 100% rename from Console/Commands/ScheduleRunCommand.php rename to src/Console/Commands/ScheduleRunCommand.php diff --git a/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php similarity index 100% rename from Console/Commands/ServeCommand.php rename to src/Console/Commands/ServeCommand.php diff --git a/Console/Commands/Traits/MakeCommandAware.php b/src/Console/Commands/Traits/MakeCommandAware.php similarity index 98% rename from Console/Commands/Traits/MakeCommandAware.php rename to src/Console/Commands/Traits/MakeCommandAware.php index 60792ca..57c2bc7 100644 --- a/Console/Commands/Traits/MakeCommandAware.php +++ b/src/Console/Commands/Traits/MakeCommandAware.php @@ -12,6 +12,7 @@ use League\Flysystem\FilesystemException; use Qubus\Exception\Data\TypeException; use Qubus\Support\Inflector; +use ReflectionException; use RuntimeException; use function str_contains; @@ -103,6 +104,8 @@ classNameSuffix: $classNameSuffix, * @param string|null $qualifiedNamespaces - will return the namespace for the stub command * @return void * @throws MakeCommandFileAlreadyExistsException + * @throws \Qubus\Exception\Exception + * @throws ReflectionException */ public function createClassFromStub( string $qualifiedClass, @@ -173,7 +176,7 @@ private function addOptionalDirFlag(mixed $options): string */ private function getStubFiles(string $classNameSuffix): string|false { - $files = glob(pattern: Application::$ROOT_PATH . '/vendor/codefyphp/framework/Stubs/*.stub'); + $files = glob(pattern: Application::$ROOT_PATH . '/vendor/codefyphp/codefy/src/Stubs/*.stub'); if (is_array(value: $files) && count($files)) { foreach ($files as $file) { if (is_file(filename: $file)) { diff --git a/Console/Commands/UlidCommand.php b/src/Console/Commands/UlidCommand.php similarity index 100% rename from Console/Commands/UlidCommand.php rename to src/Console/Commands/UlidCommand.php diff --git a/Console/Commands/UuidCommand.php b/src/Console/Commands/UuidCommand.php similarity index 100% rename from Console/Commands/UuidCommand.php rename to src/Console/Commands/UuidCommand.php diff --git a/src/Console/Commands/VendorPublishCommand.php b/src/Console/Commands/VendorPublishCommand.php new file mode 100644 index 0000000..0139ad9 --- /dev/null +++ b/src/Console/Commands/VendorPublishCommand.php @@ -0,0 +1,266 @@ +addOption( + name: 'provider', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'ServiceProvider class' + ) + ->addOption( + name: 'tag', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Publish group (config, migrations, etc.).' + ) + ->addOption( + name: 'force', + shortcut: 'f', + mode: InputOption::VALUE_NONE, + description: 'Overwrite existing files.' + ) + ->addOption( + name: 'list', + shortcut: 'l', + mode: InputOption::VALUE_NONE, + description: 'List publishable providers and tags.' + ) + ->setDescription(description: 'Publish any publishable assets from vendor packages') + ->setHelp( + help: <<vendor:publish publishes assets from vendor packages. +php codex vendor:publish +EOT + ); + } + + /** + * @throws FilesystemException + */ + public function handle(): int + { + // If user wants to list publishable tags instead of publishing + if ($this->input->getOption('list')) { + return $this->listPublishables(); + } + + // If user wants to publish from a specific provider + if ($this->input->getOption('provider')) { + return $this->publishDefinedProvider(); + } + + $tag = $this->input->getOption('tag'); + $force = $this->input->getOption('force'); + + $registeredProviders = $this->codefy->getRegisteredProviders(); + + foreach ($registeredProviders as $instance => $value) { + + $paths = $instance->pathsToPublish($tag); + + foreach ($paths as $from => $type) { + $destination = $this->resolveDestination($from, $type); + + if (is_dir($from)) { + $this->copyDirectory($from, $destination, $force); + } else { + $this->copyFile($from, $destination, $force); + } + } + } + + return ConsoleCommand::SUCCESS; + } + + private function resolveDestination(string $from, string $type): string + { + // Normalize trailing separators + $from = rtrim($from, $this->codefy::DS); + + return match ($type) { + 'config' => is_dir($from) ? 'config' : 'config/' . basename($from), + 'migrations' => is_dir($from) ? 'database/migrations' : 'database/migrations/' . basename($from), + default => $type, // allow explicit path fallback + }; + } + + /** + * @throws FilesystemException + */ + private function copyFile(string $from, string $to, bool $force): void + { + /** @var FileSystem $filesystem */ + $filesystem = $this->codefy->make(name: 'filesystem.default'); + + // Ensure parent directory exists in Flysystem + $dir = pathinfo($to, PATHINFO_DIRNAME); + if ($dir !== '' && ! $filesystem->directoryExists($dir)) { + $filesystem->createDirectory($dir); + } + + if (! $force && $filesystem->fileExists($to)) { + $this->output->writeln("Skipped {$to}, file exists."); + return; + } + + // Prefer streaming for large files, fallback to write() + $stream = fopen($from, 'rb'); + if ($stream !== false && method_exists($filesystem, 'writeStream')) { + $filesystem->writeStream($to, $stream); + if (is_resource($stream)) { + fclose($stream); + } + } else { + $contents = file_get_contents($from); + $filesystem->write($to, $contents === false ? '' : $contents); + } + + $this->output->writeln("Published: {$from} -> {$to}"); + } + + /** + * @throws FilesystemException + */ + private function copyDirectory(string $from, string $to, bool $force): void + { + $from = rtrim($from, $this->codefy::DS); + $to = rtrim($to, $this->codefy::DS); + + $dirIterator = new RecursiveDirectoryIterator($from, FilesystemIterator::SKIP_DOTS); + $iterator = new RecursiveIteratorIterator($dirIterator, RecursiveIteratorIterator::LEAVES_ONLY); + + foreach ($iterator as $fileInfo) { + /** @var SplFileInfo $fileInfo */ + if ($fileInfo->isDir()) { + continue; + } + + // Compute relative path robustly (works on Windows and *nix) + $absolutePath = $fileInfo->getPathname(); + $relative = substr($absolutePath, strlen($from) + 1); + $relative = str_replace(['\\', '/'], $this->codefy::DS, $relative); + + $target = $to . $this->codefy::DS . $relative; + + $this->copyFile($absolutePath, $target, $force); + } + } + + private function listPublishables(): int + { + $providers = $this->codefy->getRegisteredProviders(); + + $this->output->writeln("Available publishable resources:"); + + foreach ($providers as $instance => $value) { + + foreach (['config', 'migrations'] as $tag) { + $paths = $instance->pathsToPublish($tag); + if (!empty($paths)) { + $this->output->writeln(" - tag: {$tag}"); + foreach ($paths as $from => $type) { + $this->output->writeln(" {$from} -> {$this->resolveDestination($from, $type)}"); + } + } + } + } + + return ConsoleCommand::SUCCESS; + } + + /** + * @throws FilesystemException + */ + private function publishDefinedProvider(): int + { + $registeredProviders = $this->codefy->getRegisteredProviders(); + + $provider = $this->input->getOption('provider'); + $tag = $this->input->getOption('tag'); + $force = $this->input->getOption('force'); + + $providers = $this->findKeysLike($provider, $registeredProviders); + + foreach ($providers as $int => $p) { + + $paths = $p->pathsToPublish($tag); + + foreach ($paths as $from => $type) { + $destination = $this->resolveDestination($from, $type); + + if (is_dir($from)) { + $this->copyDirectory($from, $destination, $force); + } else { + $this->copyFile($from, $destination, $force); + } + } + } + + return ConsoleCommand::SUCCESS; + } + + /** + * Search array keys for a substring and return all matching keys. + * + * @param string $needle + * @param array $haystack + * @param bool $caseSensitive + * + * @return array List of matching keys (empty if none found). + */ + protected function findKeysLike(string $needle, array $haystack, bool $caseSensitive = true): array + { + $matches = []; + + foreach ($haystack as $key => $_) { + if (!is_string($key)) { + continue; + } + + $hay = $caseSensitive ? $key : strtolower($key); + $needleCmp = $caseSensitive ? $needle : strtolower($needle); + + if (str_contains($hay, $needleCmp)) { + $matches[] = $key; + } + } + + return $matches; + } +} diff --git a/Console/ConsoleApplication.php b/src/Console/ConsoleApplication.php similarity index 87% rename from Console/ConsoleApplication.php rename to src/Console/ConsoleApplication.php index 864730e..1867a14 100644 --- a/Console/ConsoleApplication.php +++ b/src/Console/ConsoleApplication.php @@ -14,6 +14,7 @@ use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class ConsoleApplication extends SymfonyApplication { @@ -24,7 +25,10 @@ public function __construct(protected Application $codefy) parent::__construct(name: 'CodefyPHP', version: Application::APP_VERSION); } - public function run(InputInterface $input = null, OutputInterface $output = null): int + /** + * @throws Throwable + */ + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int { $this->getCommandName(input: $input = $input ?: new ArgvInput()); @@ -33,8 +37,9 @@ public function run(InputInterface $input = null, OutputInterface $output = null /** * @throws Exception + * @throws Throwable */ - public function call($command, array $parameters = [], bool|OutputInterface $outputBuffer = null): int + public function call($command, array $parameters = [], bool|OutputInterface|null $outputBuffer = null): int { [$command, $input] = $this->parseCommand(command: $command, parameters: $parameters); @@ -75,7 +80,7 @@ protected function parseCommand(string $command, array $parameters): array */ public function output(): string { - return $this->lastOutput && method_exists($this->lastOutput, 'fetch') + return $this->lastOutput && method_exists(object_or_class: $this->lastOutput, method: 'fetch') ? $this->lastOutput->fetch() : ''; } diff --git a/Console/ConsoleCommand.php b/src/Console/ConsoleCommand.php similarity index 63% rename from Console/ConsoleCommand.php rename to src/Console/ConsoleCommand.php index 0dcc26f..f3e661d 100644 --- a/Console/ConsoleCommand.php +++ b/src/Console/ConsoleCommand.php @@ -9,13 +9,20 @@ use ReflectionException; use ReflectionMethod; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; use function count; use function method_exists; +use function str_repeat; + +use const PHP_EOL; abstract class ConsoleCommand extends SymfonyCommand { @@ -102,55 +109,60 @@ protected function getOptions(?string $key = null): mixed * Outputs the string to the console without any tag. * * @param string $string - * @return mixed */ - protected function terminalRaw(string $string): mixed + protected function terminalRaw(string $string): void { - return $this->output->writeln(messages: $string); + $this->output->writeln(messages: $string); } /** * Output to the terminal wrap in info tags. * * @param string $string - * @return string */ - protected function terminalInfo(string $string): mixed + protected function terminalInfo(string $string): void { - return $this->output->writeln(messages: '' . $string . ''); + $this->output->writeln(messages: '' . $string . ''); } /** * Output to the terminal wrap in comment tags. * * @param string $string - * @return string */ - protected function terminalComment(string $string): mixed + protected function terminalComment(string $string): void { - return $this->output->writeln(messages: '' . $string . ''); + $this->output->writeln(messages: '' . $string . ''); } /** * Output to the terminal wrap in question tags. * * @param string $string - * @return string */ - protected function terminalQuestion(string $string): mixed + protected function terminalQuestion(string $string): void { - return $this->output->writeln(messages: '' . $string . ''); + $this->output->writeln(messages: '' . $string . ''); } /** * Output to the terminal wrap in error tags. * * @param string $string - * @return string */ - protected function terminalError(string $string): mixed + protected function terminalError(string $string): void { - return $this->output->writeln(messages: '' . $string . ''); + $this->output->writeln(messages: '' . $string . ''); + } + + /** + * Output to the terminal with a blank line. + * + * @param int $count + */ + protected function terminalNewLine(int $count = 1): void + { + $this->output->write(str_repeat(PHP_EOL, $count)); } /** @@ -177,6 +189,78 @@ private function setArguments(): ConsoleCommand|bool return true; } + /** + * @param string $question + * @return mixed + */ + protected function confirm(string $question): mixed + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper(name: 'question'); + $question = new ConfirmationQuestion(question: $question, default: false); + + return $helper->ask($this->input, $this->output, $question); + } + + /** + * @param string $question + * @param bool|float|int|string|null $default + * @return mixed + */ + protected function ask(string $question, bool|float|int|null|string $default = null): mixed + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper(name: 'question'); + $question = new Question(question: $question, default: $default); + + return $helper->ask($this->input, $this->output, $question); + } + + /** + * @param string $question + * @param array $choices + * @param bool|float|int|string|null $default + * @param string|null $message + * @return mixed + */ + protected function choice( + string $question, + array $choices, + bool|float|int|null|string $default = null, + ?string $message = null + ): mixed { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper(name: 'question'); + $question = new ChoiceQuestion(question: $question, choices: $choices, default: $default); + + $question->setErrorMessage(errorMessage: $message ?? 'There is an error.'); + + return $helper->ask($this->input, $this->output, $question); + } + + /** + * @param string $question + * @param array $choices + * @param bool|float|int|string|null $default + * @param string|null $message + * @return mixed + */ + protected function multiChoice( + string $question, + array $choices, + bool|float|int|null|string $default = null, + ?string $message = null + ): mixed { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper(name: 'question'); + $question = new ChoiceQuestion(question: $question, choices: $choices, default: $default); + + $question->setMultiselect(multiselect: true); + $question->setErrorMessage(errorMessage: $message ?? 'There is an error.'); + + return $helper->ask($this->input, $this->output, $question); + } + /** * @return bool|ConsoleCommand * @throws TypeException diff --git a/Console/ConsoleKernel.php b/src/Console/ConsoleKernel.php similarity index 72% rename from Console/ConsoleKernel.php rename to src/Console/ConsoleKernel.php index 57b1c7a..eca4301 100644 --- a/Console/ConsoleKernel.php +++ b/src/Console/ConsoleKernel.php @@ -6,10 +6,13 @@ use Codefy\Framework\Application; use Codefy\Framework\Console\ConsoleApplication as Codex; +use Codefy\Framework\Contracts\Console\Kernel; use Codefy\Framework\Factory\FileLoggerSmtpFactory; use Codefy\Framework\Scheduler\Mutex\Locker; use Codefy\Framework\Scheduler\Schedule; +use Codefy\Framework\Support\DefaultCommands; use Exception; +use Psr\EventDispatcher\EventDispatcherInterface; use Qubus\Support\DateTime\QubusDateTimeZone; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\CommandNotFoundException; @@ -17,6 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function array_unique; use function Qubus\Inheritance\Helpers\tap; class ConsoleKernel implements Kernel @@ -46,13 +50,19 @@ protected function defineConsoleSchedule(): void $this->codefy->share(nameOrInstance: Schedule::class); $this->codefy->prepare(name: Schedule::class, callableOrMethodStr: function () { + $dispatcher = $this->codefy->make(name: EventDispatcherInterface::class); + $timeZone = new QubusDateTimeZone(timezone: $this->codefy->make( name: 'codefy.config' )->getConfigKey('app.timezone')); $mutex = $this->codefy->make(name: Locker::class); - return tap(value: new Schedule(timeZone: $timeZone, mutex: $mutex), callback: function ($schedule) { + return tap(value: new Schedule( + dispatcher: $dispatcher, + timeZone: $timeZone, + mutex: $mutex + ), callback: function ($schedule) { $this->schedule(schedule: $schedule); }); }); @@ -89,14 +99,32 @@ protected function commands(): void /** * Registers a command. * - * @param Command $command + * @param callable|Command $command * @return void */ - public function registerCommand(Command $command): void + public function registerCommand(callable|Command $command): void { $this->getCodex()->add(command: $command); } + /** + * Add an array of commands to the console. + * + * @param array $commands + * @return void + */ + public function addCommands(array $commands): void + { + foreach ($commands as $command) { + if (is_callable($command)) { + $command = $command($this->codefy); + } else { + $command = $this->codefy->make(name: $command); + } + $this->registerCommand($command); + } + } + /** * {@inheritDoc} */ @@ -147,13 +175,30 @@ protected function getCodex(): Codex return $this->codex; } + /** + * @param array $commands + * @return void + */ + protected function load(array $commands = []): void + { + $commands = array_unique( + array_merge( + $this->commands, + $this->codefy->make(name: 'codefy.config')->getConfigKey('app.commands'), + new DefaultCommands($commands)->toArray() + ) + ); + + $this->addCommands($commands); + } + /** * {@inheritDoc} * * @throws CommandNotFoundException * @throws Exception */ - public function call(string $command, array $parameters = [], bool|OutputInterface $outputBuffer = null): int + public function call(string $command, array $parameters = [], bool|OutputInterface|null $outputBuffer = null): int { $this->bootstrap(); diff --git a/Console/Exceptions/MakeCommandFileAlreadyExistsException.php b/src/Console/Exceptions/MakeCommandFileAlreadyExistsException.php similarity index 100% rename from Console/Exceptions/MakeCommandFileAlreadyExistsException.php rename to src/Console/Exceptions/MakeCommandFileAlreadyExistsException.php diff --git a/Console/Kernel.php b/src/Contracts/Console/Kernel.php similarity index 91% rename from Console/Kernel.php rename to src/Contracts/Console/Kernel.php index 2abad8d..6f9a7aa 100644 --- a/Console/Kernel.php +++ b/src/Contracts/Console/Kernel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Codefy\Framework\Console; +namespace Codefy\Framework\Contracts\Console; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -47,5 +47,5 @@ public function bootstrap(): void; * @param bool|OutputInterface|null $outputBuffer * @return int */ - public function call(string $command, array $parameters = [], bool|OutputInterface $outputBuffer = null): int; + public function call(string $command, array $parameters = [], bool|OutputInterface|null $outputBuffer = null): int; } diff --git a/src/Contracts/Http/Kernel.php b/src/Contracts/Http/Kernel.php new file mode 100644 index 0000000..f156c45 --- /dev/null +++ b/src/Contracts/Http/Kernel.php @@ -0,0 +1,33 @@ +getContainer(); + } + return $app->getContainer()->make($name, $args); +} + +/** + * Get the available config instance. + * + * @param array|string|null $key + * @param mixed $default + * @return mixed + */ +function config(string|array|null $key, mixed $default = ''): mixed +{ + if (is_null__($key)) { + return app(name: 'codefy.config'); + } + + if (is_array($key)) { + app(name: 'codefy.config')->setConfigKey($key[0], $key[1]); + } + + return app(name: 'codefy.config')->getConfigKey($key, $default); +} + +/** + * Retrieve a fresh instance of the bootstrap. + * + * @return mixed + */ +function get_fresh_bootstrap(): mixed +{ + if (file_exists(filename: $file = getcwd() . '/bootstrap/app.php')) { + return require(realpath(path: $file)); + } elseif (file_exists(filename: $file = dirname(path: getcwd()) . '/bootstrap/app.php')) { + return require(realpath(path: $file)); + } else { + return require(realpath(path: dirname(path: getcwd()) . '/bootstrap/app.php')); + } +} + +/** + * Gets the value of an environment variable. + * + * @param string $key + * @param mixed|null $default + * @return mixed + */ +function env(string $key, mixed $default = null): mixed +{ + return \Qubus\Config\Helpers\env($key, $default); +} + +/** + * Database abstraction layer global function. + * + * @throws Exception + */ +function dbal(): Connection +{ + return Codefy::$PHP->getDbConnection(); +} + +/** + * QueryBuilder global function. + * + * @return QueryBuilder|null + * @throws Exception + */ +function queryBuilder(): ?QueryBuilder +{ + return dbal()->queryBuilder(); +} + +/** + * Alternative to PHP's native mail function with SMTP support. + * + * This is a simple mail function to see for testing or for + * sending simple email messages. + * + * @param string|array $to Recipient(s) + * @param string $subject Subject of the email. + * @param string $message The email body. + * @param array $headers An array of headers. + * @param array $attachments An array of attachments. + * @return bool + * @throws Exception|ReflectionException|\PHPMailer\PHPMailer\Exception + */ +function mail(string|array $to, string $subject, string $message, array $headers = [], array $attachments = []): bool +{ + // Instantiate CodefyMailer. + $instance = new CodefyMailer(config: app(name: 'codefy.config')); + + // Set the mailer transport. + $func = sprintf('with%s', ucfirst(config(key: 'mailer.mail_transport'))); + $instance = $instance->{$func}(); + + // Detect HTML markdown. + if (substr_count(haystack: $message, needle: '= 1) { + $instance = $instance->withHtml(isHtml: true); + } + + // Build recipient(s). + $instance = $instance->withTo(address: $to); + + // Set from name and from email from environment variables. + $fromName = __observer()->filter->applyFilter('mail.from.name', env(key: 'MAILER_FROM_NAME')); + $fromEmail = __observer()->filter->applyFilter('mail.from.email', env(key: 'MAILER_FROM_EMAIL')); + // Set charset + $charset = __observer()->filter->applyFilter('mail.charset', 'utf-8'); + + // Set email subject and body. + $instance = $instance->withSubject(subject: $subject)->withBody(data: $message); + + // Check for other headers and loop through them. + if (!empty($headers)) { + foreach ($headers as $name => $content) { + if ($name === 'cc') { + $instance = $instance->withCc(address: $content); + } + + if ($name === 'bcc') { + $instance = $instance->withBcc(address: $content); + } + + if ($name === 'replyTo') { + $instance = $instance->withReplyTo(address: $content); + } + + if ( + ! in_array(needle: $name, haystack: ['MIME-Version','to','cc','bcc','replyTo'], strict: true) + && !is_int($name) + ) { + $instance = $instance->withCustomHeader(name: (string) $name, value: $content); + } + } + } + + // Set X-Mailer header + $xMailer = __observer()->filter->applyFilter( + 'mail.xmailer', + sprintf('CodefyPHP Framework %s', Codefy::$PHP::APP_VERSION) + ); + $instance = $instance->withXMailer(xmailer: $xMailer); + + // Set email charset + $instance = $instance->withCharset(charset: $charset ?: 'utf-8'); + + // Check if there are attachments and loop through them. + if (!empty($attachments)) { + foreach ($attachments as $filename => $filepath) { + $filename = is_string(value: $filename) ? $filename : ''; + $instance = $instance->withAttachment(path: $filepath, name: $filename); + } + } + + // Set sender. + $instance = $instance->withFrom(address: $fromEmail, name: $fromName ?: ''); + + try { + return $instance->send(); + } catch (\PHPMailer\PHPMailer\Exception $e) { + FileLoggerFactory::getLogger()->error($e->getMessage(), ['function' => '\Codefy\Framework\Helpers\mail']); + return false; + } +} + +/** + * Dispatches the given `$command` through + * the CommandBus. + * + * @param Command $command + * @throws ReflectionException + * @throws CommandCouldNotBeHandledException + * @throws UnresolvableCommandHandlerException + */ +function command(Command $command): void +{ + $resolver = new NativeCommandHandlerResolver( + container: ContainerFactory::make(config: config(key: 'commandbus.container')) + ); + $odin = new Odin(bus: new SynchronousCommandBus($resolver)); + + $odin->execute($command); +} + +/** + * Queries the given query and returns + * a result if any. + * + * @throws ReflectionException + * @throws UnresolvableQueryHandlerException + */ +function ask(Query $query): mixed +{ + $resolver = new NativeQueryHandlerResolver( + container: ContainerFactory::make(config: config(key: 'querybus.aliases')) + ); + $enquirer = new Enquire(bus: new SynchronousQueryBus($resolver)); + + return $enquirer->execute($query); +} + +/** + * Normalize a URL by collapsing multiple consecutive slashes into one, + * but preserve the scheme's "://" (for http/https) and do not touch query or fragment. + * + * Examples: + * normalize_url('http://example.com//foo///bar') => 'http://example.com/foo/bar' + * normalize_url('//example.com//a') => '//example.com/a' + * normalize_url('/some//relative//path') => '/some/relative/path' + * + * @param string $url + * @return string + */ +function normalize_url(string $url): string +{ + $original = $url; + $parts = parse_url($url); + + // If parse_url fails, fall back to simple regex while protecting scheme. + if ($parts === false) { + if (preg_match('#^(https?://)#i', $url, $m)) { + $prefix = $m[1]; + $rest = substr($url, strlen($prefix)); + $rest = preg_replace(pattern: '#/+#', replacement: '/', subject: $rest); + return $prefix . $rest; + } + + if (str_starts_with($url, '//')) { + return '//' . preg_replace(pattern: '#/+#', replacement: '/', subject: substr(string: $url, offset: 2)); + } + + return preg_replace(pattern: '#/+#', replacement: '/', subject: $url); + } + + $scheme = $parts['scheme'] ?? null; + $user = $parts['user'] ?? null; + $pass = $parts['pass'] ?? null; + $host = $parts['host'] ?? null; + $port = $parts['port'] ?? null; + $path = $parts['path'] ?? ''; + $query = $parts['query'] ?? null; + $fragment = $parts['fragment'] ?? null; + + // Collapse multiple slashes in the path only. + // This preserves a single leading slash (if any) and turns '///a//b' => '/a/b'. + $path = preg_replace(pattern: '#/+#', replacement: '/', subject: $path); + + // Rebuild authority (user[:pass]@host[:port]) + $authority = ''; + if ($host !== null) { + if ($user !== null) { + $authority .= $user; + if ($pass !== null) { + $authority .= ':' . $pass; + } + $authority .= '@'; + } + + // For IPv6 host strings we ensure brackets when reconstructing authority. + $hostOut = $host; + if ( + str_contains($hostOut, ':') + && $hostOut[0] !== '[' + && filter_var($hostOut, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + ) { + $hostOut = '[' . $hostOut . ']'; + } + + $authority .= $hostOut; + if ($port !== null) { + $authority .= ':' . $port; + } + } + + $result = ''; + + if ($scheme !== null) { + // Preserve scheme and "://" + $result .= $scheme . '://'; + $result .= $authority; + } elseif ($host !== null) { + // protocol-relative or host-without-scheme: preserve leading // + $result .= '//' . $authority; + } elseif (str_starts_with($original, '//')) { + // preserve protocol-relative leading // + $result .= '//'; + } + + $result .= $path; + + if ($query !== null) { + $result .= '?' . $query; + } + + if ($fragment !== null) { + $result .= '#' . $fragment; + } + + return $result; +} + +/** + * Displays the returned translated text. + * + * @param string $string + * @return string + */ +function trans(string $string): string +{ + return t__(msgid: $string, domain: config(key: 'app.locale_domain', default: 'codefy')); +} + +/** + * Escapes a translated string to make it safe for HTML output. + * + * @throws Exception + */ +function trans_html(string $string): string +{ + return esc_html__(string: $string, domain: config(key: 'app.locale_domain', default: 'codefy')); +} + +/** + * Escapes a translated string to make it safe for HTML attribute. + * + * @throws Exception + */ +function trans_attr(string $string): string +{ + return esc_attr__(string: $string, domain: config(key: 'app.locale_domain', default: 'codefy')); +} + +/** + * Returns the url of the application. + */ +function site_url(string $path = ''): string +{ + try { + return normalize_url(Server::siteUrl($path)); + } catch (Exception $e) { + error_log($e->getMessage()); + } + + return ''; +} + +/** + * Queues an item. + * + * @param ShouldQueue $queue + * @return NodeQueue + */ +function queue(ShouldQueue $queue): NodeQueue +{ + return new NodeQueue($queue); +} diff --git a/Helpers/path.php b/src/Helpers/path.php similarity index 98% rename from Helpers/path.php rename to src/Helpers/path.php index 94accde..fb3b0a8 100644 --- a/Helpers/path.php +++ b/src/Helpers/path.php @@ -5,7 +5,6 @@ namespace Codefy\Framework\Helpers; use Codefy\Framework\Application; -use Qubus\Exception\Data\TypeException; use Qubus\Exception\Exception; use function implode; diff --git a/Http/BaseController.php b/src/Http/BaseController.php similarity index 70% rename from Http/BaseController.php rename to src/Http/BaseController.php index 2234e34..1f4f705 100644 --- a/Http/BaseController.php +++ b/src/Http/BaseController.php @@ -10,29 +10,21 @@ use Qubus\Http\Session\SessionService; use Qubus\Routing\Controller\Controller; use Qubus\Routing\Router; -use Qubus\View\Native\NativeLoader; use Qubus\View\Renderer; -use function Codefy\Framework\Helpers\config; - class BaseController extends Controller implements RoutingController { public function __construct( - protected SessionService $sessionService, - protected Router $router, - protected ?Renderer $view = null, + protected SessionService $sessionService { + get => $this->sessionService; + }, + protected Router $router { + get => $this->router; + }, + protected Renderer $view { + get => $this->view; + }, ) { - $this->setView(view: $view ?? new NativeLoader(config('view.path'))); - } - - /** - * Gets the view instance. - * - * @return Renderer - */ - public function getView(): Renderer - { - return $this->view; } /** diff --git a/src/Http/Errors/HttpRequestError.php b/src/Http/Errors/HttpRequestError.php new file mode 100644 index 0000000..c0cf7e5 --- /dev/null +++ b/src/Http/Errors/HttpRequestError.php @@ -0,0 +1,15 @@ + __observer()->filter->applyFilter('http.request.timeout', 10, $uri), + /** + * Filters the number of seconds to wait while trying to connect to a server. + * Use 0 to wait indefinitely. Default: 10. + * + * @param float $connect_timeout Number of seconds. Default: 10. + * @param string|UriInterface $uri URI object or string. + */ + 'connect_timeout' => __observer()->filter->applyFilter('http.request.connect.timeout', 10, $uri), + /** + * Filters the version of the HTTP protocol used in a request. + * + * @param string $version HTTP protocol version used (usually '1.1', '1.0' or '2'). + * Default: 1.1. + * @param string|UriInterface $uri URI object or string. + */ + 'version' => __observer()->filter->applyFilter('http.request.version', '1.1', $uri), + /** + * Filters the redirect behavior of a request. + * + * @param bool|array $allow_redirects The redirect behavior of a request. Default: false. + * @param string|UriInterface $uri URI object or string. + */ + 'allow_redirects ' => __observer()->filter->applyFilter( + 'http.request.allow.redirects', + false, + $uri + ), + 'headers' => [], + 'body' => null, + 'delay' => null, + 'http_errors' => true, + 'proxy' => false, + 'stream' => false, + + ]; + + // Pre-parse for the HEAD checks. + $options = ArgsParser::parse($options); + // By default, HEAD requests do not cause redirections. + if (isset($options['method']) && $options['method'] === RequestMethod::HEAD) { + $defaults['allow_redirects'] = false; + } + + $parsedArgs = ArgsParser::parse($options, $defaults); + /** + * Filters the arguments used in an HTTP request. + * + * @param array $parsedArgs An array of HTTP request arguments. + * @param string|UriInterface $uri URI object or string. + */ + $parsedArgs = __observer()->filter->applyFilter('http.request.args', $parsedArgs, $uri); + /** + * Filters the preemptive return value of an HTTP request. + * + * Returning a non-false value from the filter will short-circuit the HTTP request and return + * early with that value. A filter should return one of: + * + * - An array containing 'headers', 'body', and 'response' elements + * - A HttpRequestError instance + * - bool false to avoid short-circuiting the response + * + * Returning any other value may result in unexpected behavior. + * + * @param false|array|HttpRequestError $response A preemptive return value of an HTTP request. Default false. + * @param array $parsedArgs HTTP request arguments. + * @param string|UriInterface $uri URI object or string. + */ + $preempt = __observer()->filter->applyFilter('http.request.preempt', false, $parsedArgs, $uri); + if ($preempt !== false) { + return $preempt; + } + + if (is_null__($parsedArgs['headers'])) { + $parsedArgs['headers'] = []; + } + + $response = parent::request($method, $uri, $parsedArgs); + + /** + * Fires after an HTTP API response is received and before the response is returned. + * + * @param ResponseInterface|mixed $response HTTP response. + * @param string $context Context under which the hook is fired. + * @param string $class HTTP transport used. + * @param array $parsedArgs HTTP request arguments. + * @param string|UriInterface $uri URI object or string. + */ + __observer()->action->doAction( + 'http_api_debug', + $response, + 'response', + \Qubus\Http\Request::class, + $parsedArgs, + $uri + ); + + /** + * Filters a successful HTTP API response immediately before the response is returned. + * + * @param ResponseInterface $response HTTP response. + * @param array $parsedArgs HTTP request arguments. + * @param string|UriInterface $uri URI object or string. + */ + return __observer()->filter->applyFilter('http.request.response', $response, $parsedArgs, $uri); + } +} diff --git a/Http/Kernel.php b/src/Http/Kernel.php similarity index 58% rename from Http/Kernel.php rename to src/Http/Kernel.php index b36075d..091a149 100644 --- a/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -5,19 +5,23 @@ namespace Codefy\Framework\Http; use Codefy\Framework\Application; -use Codefy\Framework\Contracts\Kernel as HttpKernel; +use Codefy\Framework\Contracts\Http\Kernel as HttpKernel; use Exception; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Qubus\Error\Handlers\DebugErrorHandler; use Qubus\Error\Handlers\ErrorHandler; use Qubus\Error\Handlers\ProductionErrorHandler; -use Qubus\Http\HttpPublisher; -use Qubus\Http\ServerRequestFactory as ServerRequest; +use Qubus\Http\Emitter\SapiEmitter; +use Qubus\Http\ServerRequestFactory; use Qubus\Routing\Router; use function Codefy\Framework\Helpers\public_path; use function Codefy\Framework\Helpers\router_basepath; +use function Qubus\Config\Helpers\env; use function Qubus\Security\Helpers\__observer; +use function Qubus\Support\Helpers\is_null__; use function sprintf; use function version_compare; @@ -39,9 +43,9 @@ public function __construct(Application $codefy, Router $router) { $this->codefy = $codefy; $this->router = $router; - $this->router->setBaseMiddleware(middleware: $this->codefy->getBaseMiddlewares()); + $this->router->baseMiddleware = $this->codefy->getBaseMiddlewares(); $this->router->setBasePath(basePath: router_basepath(path: public_path())); - $this->router->setDefaultNamespace(namespace: $this->codefy->getControllerNamespace()); + $this->router->setDefaultNamespace(namespace: $this->codefy->controllerNamespace); $this->registerErrorHandler(); } @@ -54,26 +58,43 @@ public function codefy(): Application /** * @throws Exception */ - protected function dispatchRouter(): bool + protected function dispatchRouter(?ServerRequestInterface $request = null): void { - return (new HttpPublisher())->publish( - content: $this->router->match( - serverRequest: ServerRequest::fromGlobals( - server: $_SERVER, - query: $_GET, - body: $_POST, - cookies: $_COOKIE, - files: $_FILES - ) - ), - emitter: new SapiEmitter() - ); + if (is_null__(var: $request)) { + $request = ServerRequestFactory::fromGlobals( + server: $_SERVER, + query: $_GET, + body: $_POST, + cookies: $_COOKIE, + files: $_FILES + ); + } + + $response = $this->handle(request: $request); + $responseEmitter = new SapiEmitter(); + $responseEmitter->emit(response: $response); + } + + /** + * @throws Exception + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = $this->router->match(serverRequest: $request); + + $method = strtoupper($request->getMethod()); + if ($method === 'HEAD') { + $emptyBody = $this->codefy->make(name: ResponseFactoryInterface::class)->createResponse()->getBody(); + return $response->withBody($emptyBody); + } + + return $response; } /** * @throws Exception */ - public function boot(): bool + public function boot(?ServerRequestInterface $request = null): void { if ( version_compare( @@ -91,13 +112,13 @@ public function boot(): bool ); } - __observer()->action->doAction('kernel.preboot'); + __observer()->action->doAction('kernel_preboot'); if (! $this->codefy->hasBeenBootstrapped()) { $this->codefy->bootstrapWith(bootstrappers: $this->bootstrappers()); } - return $this->dispatchRouter(); + $this->dispatchRouter($request); } /** @@ -116,7 +137,7 @@ protected function bootstrappers(): array protected function registerErrorHandler(): ErrorHandler { if ($this->codefy()->hasDebugModeEnabled()) { - return new DebugErrorHandler(); + return new DebugErrorHandler(title: env(key: 'APP_NAME', default: 'CodefyPHP') . ' Error'); } return new ProductionErrorHandler(); diff --git a/src/Http/Middleware/ApiMiddleware.php b/src/Http/Middleware/ApiMiddleware.php new file mode 100644 index 0000000..6fbad3e --- /dev/null +++ b/src/Http/Middleware/ApiMiddleware.php @@ -0,0 +1,45 @@ +getHeaderLine('authorization'), 'Bearer ') && + $request->getHeaderLine('authorization') === + sprintf('Bearer %s', $this->configContainer->getConfigKey(key: 'app.api_key')) + ) { + return $handler->handle($request); + } + + return JsonResponseFactory::create(data: 'Unauthorized.', status: 401); + } +} diff --git a/Auth/Middleware/AuthenticationMiddleware.php b/src/Http/Middleware/Auth/AuthenticationMiddleware.php similarity index 89% rename from Auth/Middleware/AuthenticationMiddleware.php rename to src/Http/Middleware/Auth/AuthenticationMiddleware.php index 2283cc6..c611761 100644 --- a/Auth/Middleware/AuthenticationMiddleware.php +++ b/src/Http/Middleware/Auth/AuthenticationMiddleware.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Codefy\Framework\Auth\Middleware; +namespace Codefy\Framework\Http\Middleware\Auth; use Codefy\Framework\Auth\Sentinel; use Psr\Http\Message\ResponseInterface; @@ -15,7 +15,7 @@ final class AuthenticationMiddleware implements MiddlewareInterface { - public const AUTH_ATTRIBUTE = 'USERSESSION'; + public const string AUTH_ATTRIBUTE = 'USERSESSION'; public function __construct(protected Sentinel $auth) { diff --git a/src/Http/Middleware/Auth/ExpireUserSessionMiddleware.php b/src/Http/Middleware/Auth/ExpireUserSessionMiddleware.php new file mode 100644 index 0000000..ecc0fac --- /dev/null +++ b/src/Http/Middleware/Auth/ExpireUserSessionMiddleware.php @@ -0,0 +1,59 @@ +sessionService::$options = [ + 'cookie-name' => $this->configContainer->getConfigKey(key: 'auth.cookie_name', default: 'USERSESSID'), + 'cookie-lifetime' => 0, + ]; + $session = $this->sessionService->makeSession($request); + + /** @var UserSession $user */ + $user = $session->get(type: UserSession::class); + $user + ->clear(); + + $request = $request->withAttribute(self::SESSION_ATTRIBUTE, $session); + + $response = $handler->handle($request); + + $response = CookiesResponse::set( + response: $response, + setCookieCollection: $this->sessionService->cookie->make( + name: $this->configContainer->getConfigKey(key: 'auth.cookie_name', default: 'USERSESSID'), + value: '', + maxAge: 0 + ) + ); + + return $this->sessionService->commitSession($response, $session); + } +} diff --git a/src/Http/Middleware/Auth/UserAuthorizationMiddleware.php b/src/Http/Middleware/Auth/UserAuthorizationMiddleware.php new file mode 100644 index 0000000..9f1eddd --- /dev/null +++ b/src/Http/Middleware/Auth/UserAuthorizationMiddleware.php @@ -0,0 +1,67 @@ +isLoggedIn($request))) { + return RedirectResponseFactory::create( + $this->configContainer->getConfigKey(key: 'auth.redirect_guests_to') + )->withAddedHeader(self::HEADER_HTTP_STATUS_CODE, 'not_authorized'); + } + + return $handler->handle($request); + } + + /** + * @throws TypeException + * @throws Exception + */ + private function isLoggedIn(ServerRequestInterface $request): bool + { + $this->sessionService::$options = [ + 'cookie-name' => $this->configContainer->getConfigKey(key: 'auth.cookie_name', default: 'USERSESSID'), + ]; + $session = $this->sessionService->makeSession($request); + + /** @var UserSession $user */ + $user = $session->get(type: UserSession::class); + if ($user->isEmpty()) { + return false; + } + + return true; + } +} diff --git a/Auth/Middleware/UserSessionMiddleware.php b/src/Http/Middleware/Auth/UserSessionMiddleware.php similarity index 66% rename from Auth/Middleware/UserSessionMiddleware.php rename to src/Http/Middleware/Auth/UserSessionMiddleware.php index e198e98..4e5de24 100644 --- a/Auth/Middleware/UserSessionMiddleware.php +++ b/src/Http/Middleware/Auth/UserSessionMiddleware.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Codefy\Framework\Auth\Middleware; +namespace Codefy\Framework\Http\Middleware\Auth; use Codefy\Framework\Auth\UserSession; +use Codefy\Framework\Http\Middleware\Auth\AuthenticationMiddleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -16,7 +17,7 @@ final class UserSessionMiddleware implements MiddlewareInterface { - public const SESSION_ATTRIBUTE = 'USERSESSION'; + public const string SESSION_ATTRIBUTE = 'USERSESSION'; public function __construct(protected ConfigContainer $configContainer, protected SessionService $sessionService) { @@ -27,20 +28,21 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface try { $userDetails = $request->getAttribute(AuthenticationMiddleware::AUTH_ATTRIBUTE); + $expire = isset($request->getParsedBody()['rememberme']) + && $request->getParsedBody()['rememberme'] === 'yes' + ? $this->configContainer->getConfigKey(key: 'cookies.remember') + : $this->configContainer->getConfigKey(key: 'cookies.lifetime'); + $this->sessionService::$options = [ - 'cookie-name' => 'USERSESSID', - 'cookie-lifetime' => (int) $this->configContainer->getConfigKey( - key: 'cookies.lifetime', - default: 86400 - ), + 'cookie-name' => $this->configContainer->getConfigKey(key: 'auth.cookie_name', default: 'USERSESSID'), + 'cookie-lifetime' => (int) $expire, ]; $session = $this->sessionService->makeSession($request); /** @var UserSession $user */ $user = $session->get(type: UserSession::class); $user - ->withToken(token: $userDetails->token) - ->withRole(role: $userDetails->role); + ->withToken(token: $userDetails->token); $request = $request->withAttribute(self::SESSION_ATTRIBUTE, $user); diff --git a/src/Http/Middleware/Cache/CacheExpiresMiddleware.php b/src/Http/Middleware/Cache/CacheExpiresMiddleware.php new file mode 100644 index 0000000..1f808fd --- /dev/null +++ b/src/Http/Middleware/Cache/CacheExpiresMiddleware.php @@ -0,0 +1,21 @@ +defaultExpires($this->configContainer->getConfigKey(key: 'http-cache.default')); + parent::__construct($this->configContainer->getConfigKey(key: 'http-cache.expires')); + } +} diff --git a/src/Http/Middleware/Cache/CacheMiddleware.php b/src/Http/Middleware/Cache/CacheMiddleware.php new file mode 100644 index 0000000..a561393 --- /dev/null +++ b/src/Http/Middleware/Cache/CacheMiddleware.php @@ -0,0 +1,11 @@ +configContainer->getConfigKey(key: 'http-cache.types')); + } +} diff --git a/src/Http/Middleware/ContentCacheMiddleware.php b/src/Http/Middleware/ContentCacheMiddleware.php new file mode 100644 index 0000000..9e86bd4 --- /dev/null +++ b/src/Http/Middleware/ContentCacheMiddleware.php @@ -0,0 +1,101 @@ +handle($request); + + if (RequestMethod::GET !== $request->getMethod()) { + return $response; + } + + if ($html = $this->getCachedResponseHtml($request)) { + return $this->buildResponse($html, $response); + } + + if (200 === $response->getStatusCode()) { + $this->cacheResponse($request, $response); + } + + return $response; + } + + protected function buildResponse($html, ResponseInterface $response): ResponseInterface + { + $body = new Stream('php://memory', 'w'); + $body->write($html); + + return $response->withBody($body); + } + + protected function createKeyFromRequest(RequestInterface $request): string + { + $cacheItemKeyFactory = function (RequestInterface $request): string { + static $key = null; + if (null === $key) { + $uri = $request->getUri(); + $slugify = new Slugify(); + $key = $slugify->slugify( + string: trim( + string: $uri->getPath(), + characters: '/' + ) . ($uri->getQuery() ? '?' . $uri->getQuery() : '') + ); + } + + return md5($key); + }; + + return $cacheItemKeyFactory($request); + } + + /** + * @throws InvalidArgumentException + */ + protected function getCachedResponseHtml(RequestInterface $request) + { + return $this + ->cacheItemPool + ->getItem($this->createKeyFromRequest($request)) + ->get(); + } + + protected function cacheResponse(RequestInterface $request, ResponseInterface $response): void + { + $cacheItem = new Item($this->createKeyFromRequest($request)); + $value = (string) $response->getBody(); + $cacheItem->set($value); + + $this + ->cacheItemPool + ->save($cacheItem); + } +} diff --git a/src/Http/Middleware/CorsMiddleware.php b/src/Http/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..b10bf8a --- /dev/null +++ b/src/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,52 @@ +handle($request); + + $origin = $response->getHeaderLine('origin'); + + if ($this->configContainer->getConfigKey(key: 'cors.access-control-allow-origin')[0] !== '*') { + if ( + !$origin || !in_array( + $origin, + $this->configContainer->getConfigKey(key: 'cors.access-control-allow-origin') + ) + ) { + return $response; + } + } + + $headers = $this->configContainer->getConfigKey(key: 'cors'); + + foreach ($headers as $key => $value) { + $response = $response->withAddedHeader($key, implode(separator: ',', array: $value)); + } + + return $response; + } +} diff --git a/src/Http/Middleware/Csrf/CsrfProtectionMiddleware.php b/src/Http/Middleware/Csrf/CsrfProtectionMiddleware.php new file mode 100644 index 0000000..c85eb85 --- /dev/null +++ b/src/Http/Middleware/Csrf/CsrfProtectionMiddleware.php @@ -0,0 +1,120 @@ +handle($request); + + if (true === $this->needsProtection($request) && false === $this->tokensMatch($request)) { + return JsonResponseFactory::create( + data: 'Bad CSRF Token.', + status: $this->configContainer->getConfigKey(key: 'csrf.error_status_code') + ); + } + + return $response; + } catch (\Exception $e) { + return $handler->handle($request); + } + } + + /** + * Check for methods not defined as safe. + * + * @param ServerRequestInterface $request + * @return bool + */ + private function needsProtection(ServerRequestInterface $request): bool + { + return RequestMethod::isSafe($request->getMethod()) === false; + } + + /** + * @throws Exception + */ + private function tokensMatch(ServerRequestInterface $request): bool + { + $expected = $this->fetchToken($request); + $provided = $this->getTokenFromRequest($request); + + return hash_equals($expected, $provided); + } + + + /** + * @throws Exception + * @throws \Exception + */ + private function fetchToken(ServerRequestInterface $request): string + { + /** @var CsrfSession $csrf */ + $csrf = $request->getAttribute(CsrfTokenMiddleware::CSRF_SESSION_ATTRIBUTE); + + // Ensure the token stored previously by the CsrfTokenMiddleware is present and has a valid format. + if ( + is_string($csrf->csrfToken()) && + ctype_alnum($csrf->csrfToken()) && + strlen($csrf->csrfToken()) === $this->configContainer->getConfigKey(key: 'csrf.csrf_token_length') + ) { + return $csrf->csrfToken(); + } + + return ''; + } + + /** + * @throws Exception + */ + private function getTokenFromRequest(ServerRequestInterface $request): string + { + if ($request->hasHeader($this->configContainer->getConfigKey(key: 'csrf.header'))) { + return (string) $request->getHeaderLine($this->configContainer->getConfigKey(key: 'csrf.header')); + } + + // Handle the case for a POST form. + $body = $request->getParsedBody(); + + if ( + is_array( + $body + ) && + isset($body[$this->configContainer->getConfigKey(key: 'csrf.csrf_token')]) && + is_string($body[$this->configContainer->getConfigKey(key: 'csrf.csrf_token')]) + ) { + return $body[$this->configContainer->getConfigKey(key: 'csrf.csrf_token')]; + } + + return ''; + } +} diff --git a/src/Http/Middleware/Csrf/CsrfSession.php b/src/Http/Middleware/Csrf/CsrfSession.php new file mode 100644 index 0000000..ab02675 --- /dev/null +++ b/src/Http/Middleware/Csrf/CsrfSession.php @@ -0,0 +1,43 @@ +csrfToken = $csrfToken; + + return $this; + } + + public function equals(string $token): bool + { + return !is_null__($this->csrfToken) && $this->csrfToken === $token; + } + + public function csrfToken(): string|null + { + return $this->csrfToken; + } + + public function clear(): void + { + if (!empty($this->csrfToken)) { + unset($this->csrfToken); + } + } + + public function isEmpty(): bool + { + return empty($this->csrfToken); + } +} diff --git a/src/Http/Middleware/Csrf/CsrfTokenMiddleware.php b/src/Http/Middleware/Csrf/CsrfTokenMiddleware.php new file mode 100644 index 0000000..f01df9b --- /dev/null +++ b/src/Http/Middleware/Csrf/CsrfTokenMiddleware.php @@ -0,0 +1,98 @@ +' . "\n", + self::$current->getFieldAttr(), + self::$current->token + ); + } + + /** + * @throws Exception + */ + public function getFieldAttr(): string + { + return $this->configContainer->getConfigKey(key: 'csrf.csrf_token', default: '_token'); + } + + /** + * @inheritDoc + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + try { + $this->sessionService::$options = [ + 'cookie-name' => $this->configContainer->getConfigKey(key: 'csrf.cookie_name', default: 'CSRFSESSID'), + 'cookie-lifetime' => (int) $this->configContainer->getConfigKey(key: 'csrf.lifetime', default: 86400), + ]; + + $session = $this->sessionService->makeSession($request); + + $this->token = $this->prepareToken(session: $session); + + if ( + $request->hasHeader($this->configContainer->getConfigKey(key: 'csrf.header')) + && $request->getHeaderLine($this->configContainer->getConfigKey(key: 'csrf.header')) !== '' + ) { + $this->token = $request->getHeaderLine($this->configContainer->getConfigKey(key: 'csrf.header')); + } + + /** + * If true, the application will do a header check, if not, + * it will expect data submitted via an HTML form tag. + */ + if ($this->configContainer->getConfigKey(key: 'csrf.request_header') === true) { + $request = $request->withHeader($this->configContainer->getConfigKey(key: 'csrf.header'), $this->token); + } + + /** @var CsrfSession $csrf */ + $csrf = $session->get(CsrfSession::class); + $csrf + ->withCsrfToken($this->token); + + $response = $handler->handle( + $request + ->withAttribute(self::CSRF_SESSION_ATTRIBUTE, $csrf) + ); + + return $this->sessionService->commitSession($response, $session); + } catch (\Exception $e) { + return $handler->handle($request); + } + } +} diff --git a/src/Http/Middleware/Csrf/Traits/CsrfTokenAware.php b/src/Http/Middleware/Csrf/Traits/CsrfTokenAware.php new file mode 100644 index 0000000..3968da3 --- /dev/null +++ b/src/Http/Middleware/Csrf/Traits/CsrfTokenAware.php @@ -0,0 +1,60 @@ +configContainer->getConfigKey(key: 'csrf.salt'); + + return sha1(string: uniqid(prefix: sha1(string: $salt), more_entropy: true)); + } + + /** + * @throws Exception + */ + protected function prepareToken(HttpSession $session): string + { + // Try to retrieve an existing token from the session. + $token = $session->clientSessionId(); + + // If token isn't present in the session, we generate a new token. + if ($token === '') { + $token = $this->generateToken(); + } + return hash_hmac( + algo: $this->configContainer->getConfigKey(key: 'csrf.hash_algo', default: 'sha256'), + data: $token, + key: $this->configContainer->getConfigKey(key: 'csrf.salt') + ); + } + + /** + * @throws Exception + */ + protected function hashEquals(string $knownString, string $userString): bool + { + return hash_equals( + $knownString, + hash_hmac( + algo: $this->configContainer->getConfigKey(key: 'csrf.hash_algo', default: 'sha256'), + data: $userString, + key: $this->configContainer->getConfigKey(key: 'csrf.salt') + ) + ); + } +} diff --git a/src/Http/Middleware/Csrf/helpers.php b/src/Http/Middleware/Csrf/helpers.php new file mode 100644 index 0000000..d3ffdc2 --- /dev/null +++ b/src/Http/Middleware/Csrf/helpers.php @@ -0,0 +1,15 @@ +debugBarRenderer = $debugBarRenderer; + $this->responseFactory = $responseFactory; + $this->streamFactory = $streamFactory ?? new StreamFactory(); + } + + /** + * @inheritDoc + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($staticFile = $this->getStaticFile($request->getUri())) { + return $staticFile; + } + + $response = $handler->handle($request); + + if ($this->shouldReturnResponse($request, $response)) { + return $response; + } + + if ($this->isHtmlResponse($response)) { + return $this->attachDebugBarToHtmlResponse($response); + } + + return $this->prepareHtmlResponseWithDebugBar($response); + } + + private function shouldReturnResponse(ServerRequestInterface $request, ResponseInterface $response): bool + { + $forceHeaderValue = $request->getHeaderLine(self::FORCE_KEY); + $forceCookieValue = $request->getCookieParams()[self::FORCE_KEY] ?? ''; + $forceAttributeValue = $request->getAttribute(self::FORCE_KEY, ''); + $isForceEnable = in_array('true', [$forceHeaderValue, $forceCookieValue, $forceAttributeValue], true); + $isForceDisable = in_array('false', [$forceHeaderValue, $forceCookieValue, $forceAttributeValue], true); + + return $isForceDisable + || (!$isForceEnable && ($this->isRedirect($response) || !$this->isHtmlAccepted($request))); + } + + private function prepareHtmlResponseWithDebugBar(ResponseInterface $response): ResponseInterface + { + $head = $this->debugBarRenderer->renderHead(); + $body = $this->debugBarRenderer->render(); + $outResponseBody = $this->serializeResponse($response); + $template = '%s

DebugBar

Response:

%s
%s'; + $escapedOutResponseBody = htmlspecialchars($outResponseBody); + $result = sprintf($template, $head, $escapedOutResponseBody, $body); + + $stream = $this->streamFactory->createStream($result); + + return $this->responseFactory->createResponse() + ->withBody($stream) + ->withAddedHeader('Content-type', 'text/html'); + } + + private function attachDebugBarToHtmlResponse(ResponseInterface $response): ResponseInterface + { + $head = $this->debugBarRenderer->renderHead(); + $body = $this->debugBarRenderer->render(); + $responseBody = $response->getBody(); + + if (! $responseBody->eof() && $responseBody->isSeekable()) { + $responseBody->seek(0, SEEK_END); + } + $responseBody->write($head . $body); + + return $response; + } + + private function getStaticFile(UriInterface $uri): ?ResponseInterface + { + $path = $this->extractPath($uri); + + if (!str_starts_with($path, $this->debugBarRenderer->getBaseUrl())) { + return null; + } + + $pathToFile = substr($path, strlen($this->debugBarRenderer->getBaseUrl())); + + $fullPathToFile = $this->debugBarRenderer->getBasePath() . $pathToFile; + + if (!file_exists($fullPathToFile)) { + return null; + } + + $contentType = $this->getContentTypeByFileName($fullPathToFile); + $stream = $this->streamFactory->createStreamFromResource(fopen($fullPathToFile, 'rb')); + + return $this->responseFactory->createResponse() + ->withBody($stream) + ->withAddedHeader('Content-type', $contentType); + } + + private function extractPath(UriInterface $uri): string + { + return $uri->getPath(); + } + + private function getContentTypeByFileName(string $filename): string + { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + + $map = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'otf' => 'font/opentype', + 'eot' => 'application/vnd.ms-fontobject', + 'svg' => 'image/svg+xml', + 'ttf' => 'application/font-sfnt', + 'woff' => 'application/font-woff', + 'woff2' => 'application/font-woff2', + ]; + + return $map[$ext] ?? 'text/plain'; + } + + private function isHtmlResponse(ResponseInterface $response): bool + { + return $this->isHtml($response, 'Content-Type'); + } + + private function isHtmlAccepted(ServerRequestInterface $request): bool + { + return $this->isHtml($request, 'Accept'); + } + + private function isHtml(MessageInterface $message, string $headerName): bool + { + return str_contains($message->getHeaderLine($headerName), 'text/html'); + } + + private function isRedirect(ResponseInterface $response): bool + { + $statusCode = $response->getStatusCode(); + + return $statusCode >= 300 && $statusCode < 400 && $response->getHeaderLine('Location') !== ''; + } + + private function serializeResponse(ResponseInterface $response): string + { + $reasonPhrase = $response->getReasonPhrase(); + $headers = $this->serializeHeaders($response->getHeaders()); + $format = 'HTTP/%s %d%s%s%s'; + + if (! empty($headers)) { + $headers = "\r\n" . $headers; + } + + $headers .= "\r\n\r\n"; + + return sprintf( + $format, + $response->getProtocolVersion(), + $response->getStatusCode(), + ($reasonPhrase ? ' ' . $reasonPhrase : ''), + $headers, + $response->getBody() + ); + } + + /** + * @param array> $headers + */ + private function serializeHeaders(array $headers): string + { + $lines = []; + foreach ($headers as $header => $values) { + $normalized = $this->filterHeader($header); + foreach ($values as $value) { + $lines[] = sprintf('%s: %s', $normalized, $value); + } + } + + return implode("\r\n", $lines); + } + + private function filterHeader(string $header): string + { + $filtered = str_replace('-', ' ', $header); + $filtered = ucwords($filtered); + return str_replace(' ', '-', $filtered); + } +} diff --git a/src/Http/Middleware/HtmlMinifierMiddleware.php b/src/Http/Middleware/HtmlMinifierMiddleware.php new file mode 100644 index 0000000..70bba6c --- /dev/null +++ b/src/Http/Middleware/HtmlMinifierMiddleware.php @@ -0,0 +1,17 @@ +handle($request); + + $headers = new SecureHeaders($this->configContainer->getConfigKey(key: 'headers'))->headers(); + foreach ($headers as $key => $value) { + $response = $response->withHeader($key, $value); + } + + return $response; + } +} diff --git a/src/Http/Middleware/SecureHeaders/SecureHeaders.php b/src/Http/Middleware/SecureHeaders/SecureHeaders.php new file mode 100644 index 0000000..7aafc60 --- /dev/null +++ b/src/Http/Middleware/SecureHeaders/SecureHeaders.php @@ -0,0 +1,327 @@ + [], + 'style' => [], + ]; + + public function __construct(protected array $config = []) + { + } + + public function headers(): array + { + if (! $this->compiled) { + $this->compile(); + } + + return $this->headers; + } + + protected function compile(): void + { + $this->headers = array_merge( + $this->csp(), + $this->expectCT(), + $this->hsts(), + $this->permissionsPolicy(), + $this->miscellaneous(), + $this->clearSiteData(), + ); + + $this->compiled = true; + } + + protected function csp(): array + { + if (isset($this->config['custom-csp'])) { + if (empty($this->config['custom-csp'])) { + return []; + } + + return ['Content-Security-Policy' => $this->config['custom-csp']]; + } + + $config = $this->config['csp'] ?? []; + + if (!($config['enable'] ?? false)) { + return []; + } + + $config['script-src']['nonces'] = self::$nonces['script']; + + $config['style-src']['nonces'] = self::$nonces['style']; + + $csp = new CSPBuilder($config); + + return $csp->getHeaderArray(legacy: false); + } + + /** + * Strict Transport Security. + * + * @return array|string[] + */ + protected function hsts(): array + { + if (! $this->config['hsts']['enable']) { + return []; + } + + $hsts = sprintf('max-age=%s; preload;', $this->config['hsts']['max-age']); + + if ($this->config['hsts']['include-sub-domains']) { + $hsts .= ' includeSubDomains;'; + } + + return ['Strict-Transport-Security' => $hsts]; + } + + protected function expectCT(): array + { + $config = $this->config['expect-ct'] ?? []; + + if (!($config['enable'] ?? false)) { + return []; + } + + $build[] = $this->maxAge(); + + if ($this->config['enforce'] ?? false) { + $build[] = 'enforce'; + } + + if (!empty($this->config['report-uri'])) { + $build[] = $this->reportUri(); + } + + return ['Expect-CT' => implode(separator: ', ', array: array_filter($build))]; + } + + /** + * Generate Clear-Site-Data header. + * + * @return array + */ + protected function clearSiteData(): array + { + $config = $this->config['clear-site-data'] ?? []; + + if (!($config['enable'] ?? false)) { + return []; + } + + if ($config['all'] ?? false) { + return ['"*"']; + } + + $targets = array_intersect_key($config, [ + 'cache' => true, + 'cookies' => true, + 'storage' => true, + 'executionContexts' => true, + ]); + + $needs = array_filter($targets); + + $build = array_map(function (string $directive) { + return sprintf('"%s"', $directive); + }, array_keys($needs)); + + return ['Clear-Site-Data' => implode(separator: ', ', array: $build)]; + } + + protected function permissionsPolicy(): array + { + $config = $this->config['permissions-policy'] ?? []; + + if (!($config['enable'] ?? false)) { + return []; + } + + $build = []; + + foreach ($config as $name => $c) { + if ($name === 'enable') { + continue; + } + + if (empty($val = $this->directive($c))) { + continue; + } + + $build[] = sprintf('%s=%s', $name, $val); + } + + return ['Permissions-Policy' => $build]; + } + + /** + * Get Miscellaneous headers. + * + * @return array + */ + protected function miscellaneous(): array + { + return [ + 'X-Content-Type-Options' => $this->config['x-content-type-options'], + 'X-Download-Options' => $this->config['x-download-options'], + 'X-Frame-Options' => $this->config['x-frame-options'], + 'X-Permitted-Cross-Domain-Policies' => $this->config['x-permitted-cross-domain-policies'], + 'X-Powered-By' => $this->config['x-powered-by'] ?? ($this->config['x-power-by'] ?? + sprintf('CodefyPHP-%s', Application::APP_VERSION)), + 'X-XSS-Protection' => $this->config['x-xss-protection'], + 'Referrer-Policy' => $this->config['referrer-policy'], + 'Server' => $this->config['server'], + 'Cross-Origin-Embedder-Policy' => $this->config['cross-origin-embedder-policy'] ?? '', + 'Cross-Origin-Opener-Policy' => $this->config['cross-origin-opener-policy'] ?? '', + 'Cross-Origin-Resource-Policy' => $this->config['cross-origin-resource-policy'] ?? '', + ]; + } + + protected function maxAge(): string + { + $origin = $this->config['max-age'] ?? 1800; + + // convert to int + $age = intval(value: $origin); + + // prevent negative value + $val = max($age, 0); + + return sprintf('max-age=%d', $val); + } + + /** + * Get report-uri directive. + */ + protected function reportUri(): string + { + $uri = filter_var(value: $this->config['report-uri'], filter: FILTER_VALIDATE_URL); + + if ($uri === false) { + return ''; + } + + return sprintf('report-uri="%s"', $uri); + } + + /** + * Parse a specific permission policy value. + * + * @param array $config + * @return string + */ + protected function directive(array $config): string + { + if ($config['none'] ?? false) { + return '()'; + } elseif ($config['*'] ?? false) { + return '*'; + } + + $origins = $this->origins(origins: $config['origins'] ?? []); + + if ($config['self'] ?? false) { + array_unshift($origins, 'self'); + } + + return sprintf('(%s)', implode(separator: ' ', array: $origins)); + } + + /** + * Get valid origins. + */ + protected function origins(array $origins): array + { + // prevent user leave spaces by mistake + $trimmed = array_map(callback: 'trim', array: $origins); + + // filter array using FILTER_VALIDATE_URL + $filters = filter_var_array(array: $trimmed, options: FILTER_VALIDATE_URL); + + // get valid value + $passes = array_filter(array: $filters); + + // ensure indexes are numerically + $urls = array_values(array: $passes); + + return array_map(callback: function (string $url) { + return sprintf('"%s"', $url); + }, array: $urls); + } + + /** + * Generate random nonce value for the current request. + * + * @throws Exception + */ + public static function nonce(string $target = 'script'): string + { + $nonce = base64_encode(string: bin2hex(string: random_bytes(length: 8))); + + self::$nonces[$target][] = $nonce; + + return $nonce; + } + + /** + * Remove a specific nonce value or flush all nonces for the given target. + * + * @param string|null $target + * @param string|null $nonce + * @return void + */ + public static function removeNonce(?string $target = null, ?string $nonce = null): void + { + if ($target === null) { + self::$nonces['script'] = self::$nonces['style'] = []; + } elseif (isset(self::$nonces[$target])) { + if ($nonce === null) { + self::$nonces[$target] = []; + } elseif (false !== ($idx = array_search(needle: $nonce, haystack: self::$nonces[$target]))) { + unset(self::$nonces[$target][$idx]); + } + } + } +} diff --git a/src/Http/Middleware/Spam/HoneyPotMiddleware.php b/src/Http/Middleware/Spam/HoneyPotMiddleware.php new file mode 100644 index 0000000..3613b79 --- /dev/null +++ b/src/Http/Middleware/Spam/HoneyPotMiddleware.php @@ -0,0 +1,79 @@ +attrName = $attrName; + self::$current = $this; + } + + public static function getField(?string $name = null, ?string $label = null): string + { + $label = is_null__(var: $label) ? 'Honeypot Captcha' : esc_attr(string: $label); + + return sprintf( + '' . "\n", + $name ?? self::$current->attrName, + $label + ); + } + + public static function getHiddenField(?string $name = null): string + { + return sprintf( + '' . "\n", + $name ?? self::$current->attrName, + ); + } + + /** + * @inheritDoc + * @throws Exception + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$this->isValid($request)) { + return JsonResponseFactory::create( + data: 'Form submission error.', + status: 403 + ); + } + + return $handler->handle($request); + } + + private function isValid(ServerRequestInterface $request): bool + { + $method = strtoupper(string: $request->getMethod()); + + if (in_array(needle: $method, haystack: ['GET', 'HEAD', 'CONNECT', 'TRACE', 'OPTIONS'], strict: true)) { + return true; + } + + $data = $request->getParsedBody(); + + return isset($data[$this->attrName]) && $data[$this->attrName] === ''; + } +} diff --git a/src/Http/Middleware/Spam/ReferrerSpamMiddleware.php b/src/Http/Middleware/Spam/ReferrerSpamMiddleware.php new file mode 100644 index 0000000..f754f13 --- /dev/null +++ b/src/Http/Middleware/Spam/ReferrerSpamMiddleware.php @@ -0,0 +1,23 @@ +configContainer->getConfigKey(key: 'referrer-spam.blacklist'), $responseFactory); + } +} diff --git a/src/Http/Middleware/ThrottleMiddleware.php b/src/Http/Middleware/ThrottleMiddleware.php new file mode 100644 index 0000000..fd4fe9d --- /dev/null +++ b/src/Http/Middleware/ThrottleMiddleware.php @@ -0,0 +1,57 @@ +rateLimiter->add( + new Condition( + $this->configContainer->getConfigKey(key: 'throttle.ttl'), + $this->configContainer->getConfigKey(key: 'throttle.max_attempts'), + ) + ); + + $identifier = $request->getHeaderLine($this->configContainer->getConfigKey(key: 'csrf.header')); + + try { + $this->rateLimiter->increment($identifier); + } catch (RateException $e) { + $condition = $e->condition; + return JsonResponseFactory::create( + data: sprintf( + 'You can only make %d requests in %d seconds', + $condition->limit, + $condition->ttl + ), + status: 401 + ); + } + + return $handler->handle($request); + } +} diff --git a/src/Http/Swoole/App.php b/src/Http/Swoole/App.php new file mode 100644 index 0000000..eb4d65e --- /dev/null +++ b/src/Http/Swoole/App.php @@ -0,0 +1,90 @@ +app; + } + }, + public private(set) ?Server $server = null { + get { + return $this->server ?? $this->app->make(name: Server::class); + } + }, + public int $serverStartTimestamp = 0 { + get { + if ($this->serverStartTimestamp === 0) { + return 0; + } + return time() - $this->serverStartTimestamp; + } + } + ) { + $this->app->alias(original: App::class, alias: self::class); + } + //phpcs:enable + + public function init(callable $routesCallable): void + { + $this->initRoutes(callable: $routesCallable); + + $psr17 = new Psr17Factory(); + $bridge = new BridgeManager( + app: $this->app, + responseMerger: new ResponseMerger(), + requestFactory: new RequestFactory(uriFactory: $psr17, streamFactory: $psr17, uploadedFileFactory: $psr17) + ); + + $this->server->on( + event_name: 'request', + callback: function (SwooleRequest $request, SwooleResponse $response) use ($bridge) { + + try { + $response->header(key: 'X-Powered-By', value: 'Swoole + CodefyPHP'); + + // Boot fresh per request, ensures correct routing + $this->app->boot(); + + $bridge->process(swooleRequest: $request, swooleResponse: $response)->end(); + + flush(); + } catch (\Throwable $e) { + $response->status(http_code: 500); + $response->end(content: "Internal Server Error: {$e->getMessage()}"); + } + } + ); + } + + /** + * Starts Swoole Server + */ + public function start(): void + { + $this->serverStartTimestamp = time(); + $this->server->start(); + } + + private function initRoutes(callable $callable): void + { + $callable($this->app); + } +} diff --git a/src/Http/Swoole/BridgeManager.php b/src/Http/Swoole/BridgeManager.php new file mode 100644 index 0000000..b150d75 --- /dev/null +++ b/src/Http/Swoole/BridgeManager.php @@ -0,0 +1,66 @@ +app = $app; + $this->responseMerger = $responseMerger; + $this->requestFactory = $requestFactory; + } + + /** + * @param Request $swooleRequest + * @param Response $swooleResponse + * + * @return Response + * @throws Exception + */ + public function process( + Request $swooleRequest, + Response $swooleResponse, + ): Response { + + $psrRequest = $this->requestFactory->createServerRequest($swooleRequest); + + if (!$psrRequest->getUri()->getHost() && isset($swooleRequest->header['host'])) { + $uri = $psrRequest->getUri()->withHost($swooleRequest->header['host']); + $psrRequest = $psrRequest->withUri($uri); + } + + try { + $response = $this->app->handle($psrRequest); + + return $this->responseMerger->toSwoole($response, $swooleResponse); + } catch (Exception $e) { + $swooleResponse->status(500); + $swooleResponse->end("BridgeManager error: {$e->getMessage()}"); + return $swooleResponse; + } + } +} diff --git a/src/Http/Throttle/Condition.php b/src/Http/Throttle/Condition.php new file mode 100644 index 0000000..e248d54 --- /dev/null +++ b/src/Http/Throttle/Condition.php @@ -0,0 +1,32 @@ + $this->ttl; + set(int $value) => $this->ttl = $value; + }, + public private(set) int $limit { + get => $this->limit; + set(int $value) => $this->limit = $value; + } + ) { + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->ttl; + } +} diff --git a/src/Http/Throttle/Interval.php b/src/Http/Throttle/Interval.php new file mode 100644 index 0000000..e8c2c0f --- /dev/null +++ b/src/Http/Throttle/Interval.php @@ -0,0 +1,26 @@ + $this->expiresAt; + set(int $value) => $this->expiresAt = time() + $value; + }, + public int $count = 0 { + get => $this->count; + set(int $value) => $this->count = $value; + } + ) { + } +} diff --git a/src/Http/Throttle/RateException.php b/src/Http/Throttle/RateException.php new file mode 100644 index 0000000..f1a85aa --- /dev/null +++ b/src/Http/Throttle/RateException.php @@ -0,0 +1,37 @@ +condition; + } + } + + /** + * @param string $identifier + * @param Condition $condition + */ + public function __construct(string $identifier, Condition $condition) + { + $this->condition = $condition; + + parent::__construct( + sprintf( + 'Rate %d in %d seconds was exceeded by "%s"', + $condition->limit, + $condition->ttl, + $identifier + ) + ); + } +} diff --git a/src/Http/Throttle/RateLimiter.php b/src/Http/Throttle/RateLimiter.php new file mode 100644 index 0000000..ec808f1 --- /dev/null +++ b/src/Http/Throttle/RateLimiter.php @@ -0,0 +1,150 @@ +cache = $cache; + } + + /** + * @param Condition $condition + * @return RateLimiter + */ + public function add(Condition $condition): RateLimiter + { + foreach ($this->conditions as $existing) { + if ($condition->ttl === $existing->ttl) { + throw new LogicException( + sprintf('This instance already has a condition with a ttl of %d', $existing->ttl) + ); + } elseif ($condition->ttl > $existing->ttl && $condition->limit <= $existing->limit) { + throw new LogicException( + sprintf( + 'Adding a condition of ttl %d, limit %d will never be + reached due to existing condition of ttl %d, limit %d', + $condition->ttl, + $condition->limit, + $existing->ttl, + $existing->limit + ) + ); + } elseif ($condition->ttl < $existing->ttl && $condition->limit >= $existing->limit) { + throw new LogicException( + sprintf( + 'Adding a condition of ttl %d, limit %d will prevent existing + condition of ttl %d, limit %d from being reached', + $condition->ttl, + $condition->limit, + $existing->ttl, + $existing->limit + ) + ); + } + } + $this->conditions[] = $condition; + + return $this; + } + + /** + * @param string $identifier + * @param int $count + * @return $this + * @throws RateException + * @throws InvalidArgumentException + */ + public function increment(string $identifier, int $count = 1): RateLimiter + { + foreach ($this->conditions as $condition) { + $item = $this->getItem($identifier, $condition); + if ($item->isHit()) { + /** @var Interval $interval */ + $interval = $item->get(); + } else { + $interval = new Interval($condition->ttl); + } + $item->expiresAfter($condition->ttl); + $interval->count += $count; + $item->set($interval); + $this->cache->save($item); + + if ($interval->count > $condition->limit) { + throw new RateException($identifier, $condition); + } + } + + return $this; + } + + /** + * @param string $identifier + * @return Interval[] + * @throws InvalidArgumentException + */ + public function getIntervals(string $identifier): array + { + $intervals = []; + foreach ($this->conditions as $condition) { + $item = $this->getItem($identifier, $condition); + $interval = $item->isHit() ? $item->get() : new Interval($condition->ttl); + $intervals[] = $interval; + } + + return $intervals; + } + + /** + * Reset counter + * + * @param string $identifier + * @return $this + * @throws InvalidArgumentException + */ + public function reset(string $identifier): RateLimiter + { + foreach ($this->conditions as $condition) { + $key = sprintf('%s-%s', $identifier, $condition); + $this->cache->deleteItem(md5($key)); + } + + return $this; + } + + /** + * @param string $identifier + * @param Condition $condition + * @return CacheItemInterface + * @throws InvalidArgumentException + */ + private function getItem(string $identifier, Condition $condition): CacheItemInterface + { + $key = sprintf('%s-%s', $identifier, $condition); + return $this->cache->getItem(md5($key)); + } +} diff --git a/Pipeline/Chainable.php b/src/Pipeline/Chainable.php similarity index 100% rename from Pipeline/Chainable.php rename to src/Pipeline/Chainable.php diff --git a/Pipeline/Pipeline.php b/src/Pipeline/Pipeline.php similarity index 96% rename from Pipeline/Pipeline.php rename to src/Pipeline/Pipeline.php index d72b254..5405f4a 100644 --- a/Pipeline/Pipeline.php +++ b/src/Pipeline/Pipeline.php @@ -7,7 +7,7 @@ use Closure; use Codefy\Framework\Support\Traits\DbTransactionsAware; use Exception; -use Qubus\Inheritance\ActionAware; +use Qubus\EventDispatcher\ActionFilter\Traits\ActionAware; use Qubus\Injector\ServiceContainer; use RuntimeException; use Throwable; @@ -109,7 +109,7 @@ public function then(Closure $destination): mixed { try { $this->doAction( - 'pipeline.started', + 'pipeline_started', $destination, $this->passable, $this->pipes(), @@ -129,7 +129,7 @@ public function then(Closure $destination): mixed $this->commitTransaction(); $this->doAction( - 'pipeline.finished', + 'pipeline_finished', $destination, $this->passable, $this->pipes(), @@ -197,7 +197,7 @@ protected function carry(): Closure { return function ($stack, $pipe) { return function ($passable) use ($stack, $pipe) { - $this->doAction('pipeline.execution.started', $pipe, $passable); + $this->doAction('pipeline_execution_started', $pipe, $passable); if (is_callable($pipe)) { // If the pipe is a callable, then we will call it directly, but otherwise we @@ -205,7 +205,7 @@ protected function carry(): Closure // the appropriate method and arguments, returning the results back out. $result = $pipe($passable, $stack); - $this->doAction('pipeline.execution.finished', $pipe, $passable); + $this->doAction('pipeline_execution_finished', $pipe, $passable); return $result; } elseif (! is_object($pipe)) { @@ -228,7 +228,7 @@ protected function carry(): Closure ? $pipe->{$this->method}(...$parameters) : $pipe(...$parameters); - $this->doAction('pipeline.execution.finished', $pipe, $passable); + $this->doAction('pipeline_execution_finished', $pipe, $passable); return $this->handleCarry($carry); }; diff --git a/Pipeline/PipelineBuilder.php b/src/Pipeline/PipelineBuilder.php similarity index 80% rename from Pipeline/PipelineBuilder.php rename to src/Pipeline/PipelineBuilder.php index d5de34e..32017a8 100644 --- a/Pipeline/PipelineBuilder.php +++ b/src/Pipeline/PipelineBuilder.php @@ -4,7 +4,7 @@ namespace Codefy\Framework\Pipeline; -use Codefy\Framework\Application; +use Codefy\Framework\Proxy\Codefy; final class PipelineBuilder { @@ -24,6 +24,6 @@ public function pipe(callable $pipe): self public function build(): Chainable { - return new Pipeline(Application::$APP->getContainer()); + return new Pipeline(Codefy::$PHP->getContainer()); } } diff --git a/src/Pipeline/PipelineFactory.php b/src/Pipeline/PipelineFactory.php new file mode 100644 index 0000000..1329c4a --- /dev/null +++ b/src/Pipeline/PipelineFactory.php @@ -0,0 +1,22 @@ + $pipes + * @return Chainable + */ + public function create(iterable $pipes): Chainable + { + $builder = new PipelineBuilder(); + foreach ($pipes as $pipe) { + $builder->pipe($pipe); + } + + return $builder->build(); + } +} diff --git a/src/Providers/AssetsServiceProvider.php b/src/Providers/AssetsServiceProvider.php new file mode 100644 index 0000000..7815b1b --- /dev/null +++ b/src/Providers/AssetsServiceProvider.php @@ -0,0 +1,54 @@ +codefy->configContainer->getConfigKey(key: 'assets'); + + // No groups defined. Assume the config is for the default group. + if (!isset($config['default'])) { + $this->registerAssetsManagerInstance(name: 'default', config: $config); + + return; + } + + // Multiple groups + foreach ($config as $groupName => $groupConfig) { + $this->registerAssetsManagerInstance($groupName, (array) $groupConfig); + } + } + + /** + * Register an instance of the assets manager library. + * + * @param string $name Name of the group. + * @param array $config Config of the group. + * + * @return void + */ + protected function registerAssetsManagerInstance(string $name, array $config): void + { + $this->codefy->singleton("assets.group.$name", function () use ($config) { + + if (! isset($config['public_dir'])) { + $config['public_dir'] = public_path(); + } + + return new Assets($this->codefy, $config); + }); + } +} diff --git a/Providers/ConfigServiceProvider.php b/src/Providers/ConfigServiceProvider.php similarity index 85% rename from Providers/ConfigServiceProvider.php rename to src/Providers/ConfigServiceProvider.php index 3cb1834..e53836c 100644 --- a/Providers/ConfigServiceProvider.php +++ b/src/Providers/ConfigServiceProvider.php @@ -7,11 +7,17 @@ use Codefy\Framework\Support\CodefyServiceProvider; use Qubus\Config\Collection; use Qubus\Config\Configuration; +use Qubus\Config\Path\PathNotFoundException; +use Qubus\Exception\Exception; use function Codefy\Framework\Helpers\env; final class ConfigServiceProvider extends CodefyServiceProvider { + /** + * @throws PathNotFoundException + * @throws Exception + */ public function register(): void { $this->codefy->defineParam( diff --git a/src/Providers/DatabaseConnectionServiceProvider.php b/src/Providers/DatabaseConnectionServiceProvider.php new file mode 100644 index 0000000..991ec81 --- /dev/null +++ b/src/Providers/DatabaseConnectionServiceProvider.php @@ -0,0 +1,20 @@ +codefy->singleton(Connection::class, function () { + return $this->codefy->getDbConnection(); + }); + + $this->codefy->share(nameOrInstance: Connection::class); + } +} diff --git a/src/Providers/EventDispatcherServiceProvider.php b/src/Providers/EventDispatcherServiceProvider.php new file mode 100644 index 0000000..b2daafa --- /dev/null +++ b/src/Providers/EventDispatcherServiceProvider.php @@ -0,0 +1,33 @@ +codefy->configContainer->getConfigKey( + key: 'app.event_listener', + default: SimpleProvider::class + ); + $dispatcher = $this->codefy->configContainer->getConfigKey( + key: 'app.event_dispatcher', + default: EventDispatcher::class + ); + + $this->codefy->alias(original: ListenerProviderInterface::class, alias: $provider); + $this->codefy->alias(original: EventDispatcherInterface::class, alias: $dispatcher); + } +} diff --git a/Providers/FlysystemServiceProvider.php b/src/Providers/FlysystemServiceProvider.php similarity index 100% rename from Providers/FlysystemServiceProvider.php rename to src/Providers/FlysystemServiceProvider.php diff --git a/src/Providers/LocalizationServiceProvider.php b/src/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..c41f94d --- /dev/null +++ b/src/Providers/LocalizationServiceProvider.php @@ -0,0 +1,48 @@ +codefy->isRunningInConsole()) { + return; + } + + // Instantiate MoLoader for loading .mo files. + $loader = new MoLoader(); + // Retrieve the current locale. + $locale = $this->codefy->configContainer->getConfigKey(key: 'app.locale'); + // Retrieve the current locale domain. + $domain = $this->codefy->configContainer->getConfigKey(key: 'app.locale_domain'); + // Set translation array for push. + $translations = []; + // Relative path to domain file. + $domainFilename = sprintf('%s/%s-%s.mo', $locale, $domain, $locale); + // Absolute path to the .mo file. + $mofile = $this->codefy->localePath() . $this->codefy::DS . $domainFilename; + + if (file_exists($mofile)) { + $translations[] = $loader->loadFile(filename: $mofile)->setDomain(domain: $domain); + } + + $gettext = Translator::createFromTranslations(...$translations); + + TranslatorFunctions::register($gettext); + } +} diff --git a/src/Providers/PdoServiceProvider.php b/src/Providers/PdoServiceProvider.php new file mode 100644 index 0000000..c78728b --- /dev/null +++ b/src/Providers/PdoServiceProvider.php @@ -0,0 +1,20 @@ +codefy->singleton(PDO::class, function () { + return $this->codefy->getDbConnection()->pdo; + }); + + $this->codefy->share(nameOrInstance: PDO::class); + } +} diff --git a/src/Providers/RouterServiceProvider.php b/src/Providers/RouterServiceProvider.php new file mode 100644 index 0000000..e789a93 --- /dev/null +++ b/src/Providers/RouterServiceProvider.php @@ -0,0 +1,42 @@ +codefy->singleton(key: Router::class, value: function () { + $router = new Router( + routeCollector: new RouteCollector( + routes: [], + basePath: $this->codefy->basePath(), + matchTypes: [] + ), + container: $this->codefy, + responseFactory: new ResponseFactory(), + resolver: new InjectorMiddlewareResolver(container: $this->codefy), + ); + + $router->setDefaultNamespace(namespace: self::NAMESPACE ?? $this->codefy->controllerNamespace); + return $router; + }); + + $this->codefy->alias(original: Psr7Router::class, alias: Router::class); + $this->codefy->alias(original: 'router', alias: Router::class); + $this->codefy->share(nameOrInstance: Psr7Router::class); + $this->codefy->share(nameOrInstance: Router::class); + $this->codefy->share(nameOrInstance: 'router'); + } +} diff --git a/src/Providers/RoutingServiceProvider.php b/src/Providers/RoutingServiceProvider.php new file mode 100644 index 0000000..35b7be5 --- /dev/null +++ b/src/Providers/RoutingServiceProvider.php @@ -0,0 +1,143 @@ +booting(fn () => $this->loadRoutes()); + } + + /** + * Register the callback that will be used to load the application's routes. + * + * @param Closure|callable|string|array|null $routes + * @return $this + */ + protected function routes(Closure|callable|string|array|null $routes): static + { + $this->loadRoutesUsing = $this->normalizeRoutes($routes); + + return $this; + } + + /** + * Register the callback that will be used to load the application's routes. + * + * @param Closure|callable|string|array|null $routes + * @return void + */ + public static function loadRoutesUsing(Closure|callable|string|array|null $routes): void + { + static::$alwaysLoadRoutesUsing = $routes !== null + ? static::normalizeRoutes($routes) + : null; + } + + /** + * Load the application routes. + * + * @return void + * @throws TypeException + */ + protected function loadRoutes(): void + { + if (static::$alwaysLoadRoutesUsing !== null) { + $this->codefy->execute(static::$alwaysLoadRoutesUsing, [$this->codefy->router]); + } + + if ($this->loadRoutesUsing !== null) { + $this->codefy->execute($this->loadRoutesUsing, [$this->codefy->router]); + } + } + + protected static function normalizeRoutes(Closure|callable|string|array $routes): Closure + { + return function (Router $router) use ($routes): void { + // Handle arrays recursively + if (is_array($routes)) { + foreach ($routes as $route) { + $callback = $this->normalizeRoutes($route); + $callback($router); + } + return; + } + + // Handle closures and callables + if ($routes instanceof Closure || is_callable($routes)) { + $routes($router); + return; + } + + // Handle string (file path) + if (is_string($routes)) { + $ext = pathinfo($routes, PATHINFO_EXTENSION); + + if ($ext === 'php') { + // PHP route file + if (file_exists($routes)) { + $result = new RouteFileRegistrar()->register($routes); + $result($this->codefy->router); + } + return; + } + + if ($ext === 'json') { + // JSON routes + if (file_exists($routes)) { + $this->codefy->router->loadRoutesFromJson($routes); + } + return; + } + } + + throw new RuntimeException(sprintf("Unsupported routes definition: %s", get_debug_type($routes))); + }; + } + + /** + * Pass dynamic methods onto the router instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call(string $method, array $parameters) + { + return $this->forwardCallTo( + $this->codefy->make(name: Psr7Router::class), + $method, + $parameters + ); + } +} diff --git a/Codefy.php b/src/Proxy/Codefy.php similarity index 75% rename from Codefy.php rename to src/Proxy/Codefy.php index 26bf9a5..d026b7e 100644 --- a/Codefy.php +++ b/src/Proxy/Codefy.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Codefy\Framework; +namespace Codefy\Framework\Proxy; +use Codefy\Framework\Application; use stdClass; class Codefy extends stdClass diff --git a/src/Queue/NodeQueue.php b/src/Queue/NodeQueue.php new file mode 100644 index 0000000..a8863f1 --- /dev/null +++ b/src/Queue/NodeQueue.php @@ -0,0 +1,366 @@ +queue = $queue; + $this->db = Node::open(file: $node ?? $this->queue->node()); + } + + /** + * @param string|callable $schedule + * @return bool + */ + public function isDue(string|callable $schedule): bool + { + if (is_callable($schedule)) { + return call_user_func($schedule); + } + + $dateTime = DateTime::createFromFormat('Y-m-d H:i:s', $schedule); + if ($dateTime !== false) { + return $dateTime->format('Y-m-d H:i') == (date('Y-m-d H:i')); + } + + return new CronExpression((string) $schedule)->isDue(); + } + + /** + * @inheritDoc + */ + public function createItem(): string + { + $id = ''; + + try { + $id = $this->doCreateItem(); + } catch (Exception $e) { + FileLoggerFactory::getLogger()->error( + sprintf('NODEQSTATE: %s', $e->getMessage()), + ['Queue' => 'NodeQueue::createItem'] + ); + } + + return $id; + } + + /** + * Adds a queue item and store it directly to the queue. + * + * @return string A unique ID if the item was successfully created and was (best effort) + * added to the queue, otherwise false. We don't guarantee the item was + * committed to disk etc, but as far as we know, the item is now in the + * queue. + * @throws TypeException + * @throws ReflectionException + */ + protected function doCreateItem(): string + { + $lastId = ''; + + $query = $this->db; + $query->begin(); + try { + $query->insert([ + 'name' => $this->queue->name, + 'object' => new JsonSerializer()->serialize($this->queue), + 'created' => time(), + 'expire' => (int) 0, + 'executions' => (int) 0, + ]); + $query->commit(); + $lastId = $query->lastInsertId(); + } catch (Exception $e) { + $query->rollback(); + FileLoggerFactory::getLogger()->error( + sprintf('NODEQSTATE: %s', $e->getMessage()), + ['Queue' => 'NodeQueue::doCreateItem'] + ); + } + /** + * Return the new serial ID, or false on failure. + */ + return $lastId; + } + + /** + * @inheritDoc + */ + public function numberOfItems(): int + { + try { + return count($this->db->where('name', $this->queue->name)->get()); + } catch (Exception $e) { + $this->catchException($e); + /** + * If there is no node there cannot be any items. + */ + return 0; + } + } + + /** + * @inheritDoc + */ + public function claimItem(int $leaseTime = 3600): array|object|bool + { + /** + * Claim an item by updating its expiry fields. If claim is not + * successful another thread may have claimed the item in the meantime. + * Therefore, loop until an item is successfully claimed, or we are + * reasonably sure there are no unclaimed items left. + */ + try { + $item = $this->db + ->where('expire', (int) 0) + ->where('name', $this->queue->name) + ->sortBy('created') + ->sortBy('_id') + ->first(); + } catch (Exception $e) { + $this->catchException($e); + /** + * If the node does not exist there are no items currently + * available to claim. + */ + return false; + } + if ($item) { + $update = $this->db; + $update->begin(); + try { + /** + * Try to update the item. Only one thread can succeed in + * UPDATEing the same row. We cannot rely on REQUEST_TIME + * because items might be claimed by a single consumer which + * runs longer than 1 second. If we continue to use REQUEST_TIME + * instead of the current time(), we steal time from the lease, + * and will tend to reset items before the lease should really + * expire. + */ + $update->where('expire', (int) 0)->where('_id', $item['_id']) + ->update([ + 'expire' => (int) time() + ( + $this->queue->leaseTime <= (int) 0 + ? (int) $leaseTime + : (int) $this->queue->leaseTime + ) + ]); + $update->commit(); + return $item; + } catch (Exception $e) { + $update->rollback(); + $this->catchException($e); + /** + * If the node does not exist there are no items currently + * available to claim. + */ + return false; + } + } else { + /** + * No items currently available to claim. + */ + return false; + } + } + + /** + * @inheritDoc + */ + public function deleteItem(mixed $item): void + { + $delete = $this->db; + $delete->begin(); + try { + $delete->where('_id', $item['_id']) + ->delete(); + $delete->commit(); + } catch (Exception $e) { + $delete->rollback(); + $this->catchException($e); + } + } + + /** + * @inheritDoc + */ + public function releaseItem(mixed $item): bool + { + $update = $this->db; + $update->begin(); + try { + $update->where('_id', $item['_id']) + ->update([ + 'expire' => (int) 0, + 'executions' => +1, + ]); + $update->commit(); + + return true; + } catch (Exception $e) { + $update->rollback(); + $this->catchException($e); + } + + return false; + } + + /** + * @inheritDoc + */ + public function createQueue() + { + /** + * All tasks are stored in a single node (which is created on + * demand) so there is nothing we need to do to create a new queue. + */ + } + + /** + * @inheritDoc + */ + public function deleteQueue(): void + { + $delete = $this->db; + $delete->begin(); + try { + $delete->where('name', $this->queue->name) + ->delete(); + $delete->commit(); + } catch (Exception $e) { + $delete->rollback(); + $this->catchException($e); + } + } + + /** + * @throws ReflectionException + * @throws TypeException + */ + public function garbageCollection(): void + { + $delete = $this->db; + $delete->begin(); + try { + /** + * Clean up the queue for failed batches. + */ + $delete + ->where('executions', '>=', $this->queue->executions) + ->where('name', $this->queue->name) + ->delete(); + $delete->commit(); + } catch (Exception $e) { + $delete->rollback(); + $this->catchException($e); + } + + $update = $this->db; + $update->begin(); + + try { + /** + * Reset expired items in the default queue implementation node. If + * that's not used, this will simply be a no-op. + */ + $update->where('expire', 'not in', (int) 0) + ->where('expire', '<', $_SERVER['REQUEST_TIME']) + ->update([ + 'expire' => (int) 0 + ]); + $update->commit(); + } catch (Exception $e) { + $update->rollback(); + $this->catchException($e); + } + } + + /** + * Act on an exception when queue might be stale. + * + * If the node does not yet exist, that's fine, but if the node exists and + * yet the query failed, then the queue is stale and the exception needs to + * propagate. + * + * @param Exception $e The exception. + * @throws ReflectionException + * @throws TypeException + */ + protected function catchException(Exception $e): void + { + FileLoggerSmtpFactory::getLogger()->error( + sprintf('QUEUESTATE: %s', $e->getMessage()), + ['Queue' => 'catchException'] + ); + } + + /** + * @throws ReflectionException + * @throws TypeException + */ + public function dispatch(): bool + { + /** + * Delete queues that are considered dead. + */ + $this->garbageCollection(); + + /** + * Check if queue is due or not due. + */ + if (!$this->isDue($this->queue->schedule)) { + return false; + } + + $item = $this->claimItem(); + + try { + if (false !== $item) { + $object = new JsonSerializer()->unserialize($item['object']); + + if (!$object instanceof ShouldQueue) { + $this->deleteItem($item); + return false; + }; + + if (false !== call_user_func([$object, 'handle'])) { + $this->deleteItem($item); + } else { + $this->releaseItem($item); + return false; + } + } + + return true; + } catch (Exception $e) { + $this->catchException($e); + } + + return false; + } +} diff --git a/src/Queue/Queue.php b/src/Queue/Queue.php new file mode 100644 index 0000000..e67f8bc --- /dev/null +++ b/src/Queue/Queue.php @@ -0,0 +1,99 @@ +options = $options + [ + 'expression' => null, + 'maxRuntime' => 120, + 'recipients' => null, + 'smtpSender' => null, + 'smtpSenderName' => null, + 'enabled' => true, + ]; + + if ($new->options['expression'] instanceof Expressional) { + $new->expression = $new->options['expression']; + } + + if (is_string($new->options['expression'])) { + $new->expression = $new->alias($new->options['expression']); + } + + return $new; + } + + public function withScheduler(Schedule $schedule): self + { + $new = clone $this; + $new->schedule = $schedule; + + return $new; + } + + public function withDispatcher(EventDispatcherInterface $dispatcher): self + { + $new = clone $this; + $new->dispatcher = $dispatcher; + + return $new; + } + + public function getOption(?string $option): mixed + { + if ($option === null) { + return $this->options; + } + + return $this->options[$option] ?? null; + } + + /** + * @throws TypeException + */ + public function pid(): TaskId + { + $this->pid = TaskId::fromString(); + + return $this->pid; + } + + /** + * Called before a task is executed. + */ + public function setUp(): void + { + } + + /** + * Executes a task. + */ + abstract public function execute(Schedule $schedule): void; + + /** + * Called after a task is executed. + */ + public function tearDown(): void + { + } + + /** + * Check if time hasn't arrived. + * + * @param string $datetime + * @return bool + */ + protected function notYet(string $datetime): bool + { + return time() < strtotime($datetime); + } + + /** + * Check if the time has passed. + * + * @param string $datetime + * @return bool + */ + protected function past(string $datetime): bool + { + return time() > strtotime($datetime); + } + + /** + * Check if event should be run. + * + * @param string $datetime + * @return self + */ + public function from(string $datetime): self + { + return $this->skip(function () use ($datetime) { + return $this->notYet($datetime); + }); + } + + /** + * Check if event should not run. + * + * @param string $datetime + * @return self + */ + public function to(string $datetime): self + { + return $this->skip(function () use ($datetime) { + return $this->past($datetime); + }); + } + + /** + * Checks whether the task can and should run. + */ + private function shouldRun(): bool + { + if (is_false__($this->options['enabled'])) { + return false; + } + + // If task overlaps don't execute. + if ( + $this->canRunOnlyOneInstance() && + ! $this->mutex->tryLock($this) + ) { + return false; + } + + return true; + } + + public function run(): bool + { + if (!$this->shouldRun()) { + return false; + } + + $this->dispatcher->dispatch(event: new TaskStarted($this)); + + try { + // Run code before executing task. + $this->setUp(); + // Execute task. + $this->execute($this->schedule); + // Run code after executing task. + $this->tearDown(); + } catch (Exception $ex) { + $this->dispatcher->dispatch(event: new TaskFailed($this)); + $this->sendEmail($ex); + } + + $this->dispatcher->dispatch(event: new TaskCompleted($this)); + + return true; + } +} diff --git a/src/Scheduler/Event/TaskCompleted.php b/src/Scheduler/Event/TaskCompleted.php new file mode 100644 index 0000000..dcfcd13 --- /dev/null +++ b/src/Scheduler/Event/TaskCompleted.php @@ -0,0 +1,28 @@ + $this->task; + } + + public function __construct(Task $task) + { + $this->task = $task; + } + + public function getName(): string + { + return self::EVENT_NAME; + } +} diff --git a/src/Scheduler/Event/TaskFailed.php b/src/Scheduler/Event/TaskFailed.php new file mode 100644 index 0000000..d5e18fa --- /dev/null +++ b/src/Scheduler/Event/TaskFailed.php @@ -0,0 +1,28 @@ + $this->task; + } + + public function __construct(Task $task) + { + $this->task = $task; + } + + public function getName(): string + { + return self::EVENT_NAME; + } +} diff --git a/src/Scheduler/Event/TaskSkipped.php b/src/Scheduler/Event/TaskSkipped.php new file mode 100644 index 0000000..eb9dd89 --- /dev/null +++ b/src/Scheduler/Event/TaskSkipped.php @@ -0,0 +1,18 @@ + $this->task; + } + + public function __construct(Task $task) + { + $this->task = $task; + } + + public function getName(): string + { + return self::EVENT_NAME; + } +} diff --git a/Scheduler/Expressions/At.php b/src/Scheduler/Expressions/At.php similarity index 100% rename from Scheduler/Expressions/At.php rename to src/Scheduler/Expressions/At.php diff --git a/Scheduler/Expressions/Daily.php b/src/Scheduler/Expressions/Daily.php similarity index 100% rename from Scheduler/Expressions/Daily.php rename to src/Scheduler/Expressions/Daily.php diff --git a/Scheduler/Expressions/Date.php b/src/Scheduler/Expressions/Date.php similarity index 100% rename from Scheduler/Expressions/Date.php rename to src/Scheduler/Expressions/Date.php diff --git a/Scheduler/Expressions/DayOfWeek/Friday.php b/src/Scheduler/Expressions/DayOfWeek/Friday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Friday.php rename to src/Scheduler/Expressions/DayOfWeek/Friday.php diff --git a/Scheduler/Expressions/DayOfWeek/Monday.php b/src/Scheduler/Expressions/DayOfWeek/Monday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Monday.php rename to src/Scheduler/Expressions/DayOfWeek/Monday.php diff --git a/Scheduler/Expressions/DayOfWeek/Saturday.php b/src/Scheduler/Expressions/DayOfWeek/Saturday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Saturday.php rename to src/Scheduler/Expressions/DayOfWeek/Saturday.php diff --git a/Scheduler/Expressions/DayOfWeek/Sunday.php b/src/Scheduler/Expressions/DayOfWeek/Sunday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Sunday.php rename to src/Scheduler/Expressions/DayOfWeek/Sunday.php diff --git a/Scheduler/Expressions/DayOfWeek/Thursday.php b/src/Scheduler/Expressions/DayOfWeek/Thursday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Thursday.php rename to src/Scheduler/Expressions/DayOfWeek/Thursday.php diff --git a/Scheduler/Expressions/DayOfWeek/Tuesday.php b/src/Scheduler/Expressions/DayOfWeek/Tuesday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Tuesday.php rename to src/Scheduler/Expressions/DayOfWeek/Tuesday.php diff --git a/Scheduler/Expressions/DayOfWeek/Wednesday.php b/src/Scheduler/Expressions/DayOfWeek/Wednesday.php similarity index 100% rename from Scheduler/Expressions/DayOfWeek/Wednesday.php rename to src/Scheduler/Expressions/DayOfWeek/Wednesday.php diff --git a/Scheduler/Expressions/EveryMinute.php b/src/Scheduler/Expressions/EveryMinute.php similarity index 100% rename from Scheduler/Expressions/EveryMinute.php rename to src/Scheduler/Expressions/EveryMinute.php diff --git a/Scheduler/Expressions/Expressional.php b/src/Scheduler/Expressions/Expressional.php similarity index 100% rename from Scheduler/Expressions/Expressional.php rename to src/Scheduler/Expressions/Expressional.php diff --git a/Scheduler/Expressions/Hourly.php b/src/Scheduler/Expressions/Hourly.php similarity index 100% rename from Scheduler/Expressions/Hourly.php rename to src/Scheduler/Expressions/Hourly.php diff --git a/Scheduler/Expressions/MonthOfYear/April.php b/src/Scheduler/Expressions/MonthOfYear/April.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/April.php rename to src/Scheduler/Expressions/MonthOfYear/April.php diff --git a/Scheduler/Expressions/MonthOfYear/August.php b/src/Scheduler/Expressions/MonthOfYear/August.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/August.php rename to src/Scheduler/Expressions/MonthOfYear/August.php diff --git a/Scheduler/Expressions/MonthOfYear/December.php b/src/Scheduler/Expressions/MonthOfYear/December.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/December.php rename to src/Scheduler/Expressions/MonthOfYear/December.php diff --git a/Scheduler/Expressions/MonthOfYear/February.php b/src/Scheduler/Expressions/MonthOfYear/February.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/February.php rename to src/Scheduler/Expressions/MonthOfYear/February.php diff --git a/Scheduler/Expressions/MonthOfYear/January.php b/src/Scheduler/Expressions/MonthOfYear/January.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/January.php rename to src/Scheduler/Expressions/MonthOfYear/January.php diff --git a/Scheduler/Expressions/MonthOfYear/July.php b/src/Scheduler/Expressions/MonthOfYear/July.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/July.php rename to src/Scheduler/Expressions/MonthOfYear/July.php diff --git a/Scheduler/Expressions/MonthOfYear/June.php b/src/Scheduler/Expressions/MonthOfYear/June.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/June.php rename to src/Scheduler/Expressions/MonthOfYear/June.php diff --git a/Scheduler/Expressions/MonthOfYear/March.php b/src/Scheduler/Expressions/MonthOfYear/March.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/March.php rename to src/Scheduler/Expressions/MonthOfYear/March.php diff --git a/Scheduler/Expressions/MonthOfYear/May.php b/src/Scheduler/Expressions/MonthOfYear/May.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/May.php rename to src/Scheduler/Expressions/MonthOfYear/May.php diff --git a/Scheduler/Expressions/MonthOfYear/November.php b/src/Scheduler/Expressions/MonthOfYear/November.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/November.php rename to src/Scheduler/Expressions/MonthOfYear/November.php diff --git a/Scheduler/Expressions/MonthOfYear/October.php b/src/Scheduler/Expressions/MonthOfYear/October.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/October.php rename to src/Scheduler/Expressions/MonthOfYear/October.php diff --git a/Scheduler/Expressions/MonthOfYear/September.php b/src/Scheduler/Expressions/MonthOfYear/September.php similarity index 100% rename from Scheduler/Expressions/MonthOfYear/September.php rename to src/Scheduler/Expressions/MonthOfYear/September.php diff --git a/Scheduler/Expressions/Monthly.php b/src/Scheduler/Expressions/Monthly.php similarity index 100% rename from Scheduler/Expressions/Monthly.php rename to src/Scheduler/Expressions/Monthly.php diff --git a/Scheduler/Expressions/Quarterly.php b/src/Scheduler/Expressions/Quarterly.php similarity index 100% rename from Scheduler/Expressions/Quarterly.php rename to src/Scheduler/Expressions/Quarterly.php diff --git a/Scheduler/Expressions/WeekDays.php b/src/Scheduler/Expressions/WeekDays.php similarity index 100% rename from Scheduler/Expressions/WeekDays.php rename to src/Scheduler/Expressions/WeekDays.php diff --git a/Scheduler/Expressions/WeekEnds.php b/src/Scheduler/Expressions/WeekEnds.php similarity index 100% rename from Scheduler/Expressions/WeekEnds.php rename to src/Scheduler/Expressions/WeekEnds.php diff --git a/Scheduler/Expressions/Weekly.php b/src/Scheduler/Expressions/Weekly.php similarity index 100% rename from Scheduler/Expressions/Weekly.php rename to src/Scheduler/Expressions/Weekly.php diff --git a/src/Scheduler/FailedProcessor.php b/src/Scheduler/FailedProcessor.php new file mode 100644 index 0000000..2484ea7 --- /dev/null +++ b/src/Scheduler/FailedProcessor.php @@ -0,0 +1,25 @@ + $this->processor; + }, + private Exception $exception { + get => $this->exception; + } + ) { + } +} diff --git a/Scheduler/Mutex/CacheLocker.php b/src/Scheduler/Mutex/CacheLocker.php similarity index 100% rename from Scheduler/Mutex/CacheLocker.php rename to src/Scheduler/Mutex/CacheLocker.php diff --git a/Scheduler/Mutex/Locker.php b/src/Scheduler/Mutex/Locker.php similarity index 100% rename from Scheduler/Mutex/Locker.php rename to src/Scheduler/Mutex/Locker.php diff --git a/Scheduler/Processor/BaseProcessor.php b/src/Scheduler/Processor/BaseProcessor.php similarity index 99% rename from Scheduler/Processor/BaseProcessor.php rename to src/Scheduler/Processor/BaseProcessor.php index c6a74e5..ebd0216 100644 --- a/Scheduler/Processor/BaseProcessor.php +++ b/src/Scheduler/Processor/BaseProcessor.php @@ -249,6 +249,8 @@ public function maxRuntime(): int */ public function getExpression(): ?string { + $expression = null; + if ($this->expression instanceof CronExpression) { $expression = $this->expression->getExpression(); } diff --git a/Scheduler/Processor/Callback.php b/src/Scheduler/Processor/Callback.php similarity index 90% rename from Scheduler/Processor/Callback.php rename to src/Scheduler/Processor/Callback.php index 0945e2c..bd06bbe 100644 --- a/Scheduler/Processor/Callback.php +++ b/src/Scheduler/Processor/Callback.php @@ -10,7 +10,7 @@ class Callback extends BaseProcessor implements Stringable, Processor { - public function run(): mixed + public function run(): string|false { if ( $this->preventOverlapping && @@ -31,7 +31,7 @@ public function run(): mixed /** * Executes command. */ - private function exec(callable $fn): mixed + private function exec(callable $fn): string { $data = $this->call($fn, $this->args, true); diff --git a/Scheduler/Processor/Dispatcher.php b/src/Scheduler/Processor/Dispatcher.php similarity index 94% rename from Scheduler/Processor/Dispatcher.php rename to src/Scheduler/Processor/Dispatcher.php index 6aab8b5..486cf75 100644 --- a/Scheduler/Processor/Dispatcher.php +++ b/src/Scheduler/Processor/Dispatcher.php @@ -4,7 +4,7 @@ namespace Codefy\Framework\Scheduler\Processor; -use Qubus\EventDispatcher\EventDispatcher; +use Qubus\EventDispatcher\Legacy\EventDispatcher; use Stringable; class Dispatcher extends BaseProcessor implements Stringable, Processor diff --git a/Scheduler/Processor/Processor.php b/src/Scheduler/Processor/Processor.php similarity index 100% rename from Scheduler/Processor/Processor.php rename to src/Scheduler/Processor/Processor.php diff --git a/Scheduler/Processor/Shell.php b/src/Scheduler/Processor/Shell.php similarity index 100% rename from Scheduler/Processor/Shell.php rename to src/Scheduler/Processor/Shell.php diff --git a/Scheduler/Schedule.php b/src/Scheduler/Schedule.php similarity index 75% rename from Scheduler/Schedule.php rename to src/Scheduler/Schedule.php index 91551dc..64c0a2e 100644 --- a/Scheduler/Schedule.php +++ b/src/Scheduler/Schedule.php @@ -9,9 +9,12 @@ use Codefy\Framework\Scheduler\Processor\Processor; use Codefy\Framework\Scheduler\Processor\Shell; use Codefy\Framework\Scheduler\Traits\LiteralAware; +use Exception; +use Psr\EventDispatcher\EventDispatcherInterface; use Qubus\Exception\Data\TypeException; -use Qubus\Exception\Exception; use Qubus\Support\DateTime\QubusDateTimeZone; +use ReflectionClass; +use ReflectionException; use function array_filter; use function count; @@ -27,23 +30,35 @@ class Schedule { use LiteralAware; - public const SUNDAY = 0; - public const MONDAY = 1; - public const TUESDAY = 2; - public const WEDNESDAY = 3; - public const THURSDAY = 4; - public const FRIDAY = 5; - public const SATURDAY = 6; + public const int SUNDAY = 0; + public const int MONDAY = 1; + public const int TUESDAY = 2; + public const int WEDNESDAY = 3; + public const int THURSDAY = 4; + public const int FRIDAY = 5; + public const int SATURDAY = 6; + //phpcs:disable /** @var Processor[] $processors */ - protected array $processors = []; + protected array $processors = [] { + &get => $this->processors; + } - protected array $executedProcessors = []; + public protected(set) array $executedProcessors = [] { + &get => $this->executedProcessors; + } - protected array $failedProcessors = []; + public protected(set) array $failedProcessors = [] { + &get => $this->failedProcessors; + } - public function __construct(public readonly QubusDateTimeZone $timeZone, public readonly Locker $mutex) - { + //phpcs:enable + + public function __construct( + public readonly EventDispatcherInterface $dispatcher, + public readonly QubusDateTimeZone $timeZone, + public readonly Locker $mutex + ) { } /** @@ -97,6 +112,23 @@ public function php(string $script, ?string $bin = null, array $args = []): Shel return $command; } + /** + * @throws ReflectionException + * @throws \Qubus\Exception\Exception + */ + public function task(Task $task, array $options = []): BaseTask + { + $instance = new ReflectionClass($task); + $task = $instance->newInstanceArgs([$this->mutex, $this->timeZone]); + $task->withOptions($options); + $task->withScheduler($this); + $task->withDispatcher($this->dispatcher); + + $this->queueProcessor($task); + + return $task; + } + /** * @return Processor[] */ @@ -153,16 +185,6 @@ private function pushExecutedProcessor(Processor $processor): Processor return $processor; } - /** - * Get the executed processes. - * - * @return array - */ - public function getExecutedProcessors(): array - { - return $this->executedProcessors; - } - /** * Push a failed process. */ @@ -173,21 +195,13 @@ private function pushFailedProcessor(Processor $processor, Exception $ex): Proce return $processor; } - /** - * Get the failed jobs. - * - * @return FailedProcessor[] - */ - public function getFailedProcessors(): array - { - return $this->failedProcessors; - } - /** * Compile the Task command. */ protected function compileArguments(array $args = []): string { + $compiled = ''; + // Sanitize command arguments. foreach ($args as $key => $value) { $compiled = ' ' . escapeshellarg($key); diff --git a/src/Scheduler/Task.php b/src/Scheduler/Task.php new file mode 100644 index 0000000..d662eea --- /dev/null +++ b/src/Scheduler/Task.php @@ -0,0 +1,23 @@ + */ - protected $fieldsPosition = [ + protected array $fieldsPosition = [ 'minute' => CronExpression::MINUTE, 'hour' => CronExpression::HOUR, 'day' => CronExpression::DAY, @@ -66,19 +65,11 @@ public function cron(string $expression = '* * * * *'): self */ public function filtersPass(): bool { - foreach ($this->filters as $callback) { - if (! $this->call($callback)) { - return false; - } - } - - foreach ($this->rejects as $callback) { - if ($this->call($callback)) { - return false; - } + if (array_any($this->filters, fn($callback) => !$this->call($callback))) { + return false; } - return true; + return array_all($this->rejects, fn($callback) => !$this->call($callback)); } /** @@ -570,7 +561,7 @@ protected function expressionPasses(string|DateTimeZone|null $timezone = null): $now = $now->setTimezone($timezone); if ($this->timezone) { - $taskTimeZone = is_object($this->timezone) && $this->timezone instanceof DateTimeZone + $taskTimeZone = $this->timezone instanceof DateTimeZone ? $this->timezone ->getName() : $this->timezone; @@ -591,14 +582,6 @@ protected function expressionPasses(string|DateTimeZone|null $timezone = null): */ protected function spliceIntoPosition(int $position, string $value): self { - /*if ($this->expression instanceof CronExpression) { - $expression = $this->expression->getExpression(); - } - - if (is_string($this->expression)) { - $expression = $this->expression; - }*/ - $expression = match (true) { $this->expression instanceof CronExpression => $this->expression->getExpression(), is_string($this->expression) => $this->expression diff --git a/Scheduler/Traits/LiteralAware.php b/src/Scheduler/Traits/LiteralAware.php similarity index 100% rename from Scheduler/Traits/LiteralAware.php rename to src/Scheduler/Traits/LiteralAware.php diff --git a/Scheduler/Traits/MailerAware.php b/src/Scheduler/Traits/MailerAware.php similarity index 97% rename from Scheduler/Traits/MailerAware.php rename to src/Scheduler/Traits/MailerAware.php index 730fff0..3bc9e1c 100644 --- a/Scheduler/Traits/MailerAware.php +++ b/src/Scheduler/Traits/MailerAware.php @@ -4,7 +4,7 @@ namespace Codefy\Framework\Scheduler\Traits; -use Qubus\Exception\Exception; +use Exception; use function Codefy\Framework\Helpers\app; use function explode; diff --git a/Scheduler/Traits/ScheduleValidateAware.php b/src/Scheduler/Traits/ScheduleValidateAware.php similarity index 96% rename from Scheduler/Traits/ScheduleValidateAware.php rename to src/Scheduler/Traits/ScheduleValidateAware.php index b0721bb..d8a8c7b 100644 --- a/Scheduler/Traits/ScheduleValidateAware.php +++ b/src/Scheduler/Traits/ScheduleValidateAware.php @@ -57,8 +57,7 @@ protected static function range(int|string|array|null $value = null, int $min = } } - $value = implode(',', $value); - return $value; + return implode(',', $value); } if ( diff --git a/Scheduler/ValueObject/TaskId.php b/src/Scheduler/ValueObject/TaskId.php similarity index 100% rename from Scheduler/ValueObject/TaskId.php rename to src/Scheduler/ValueObject/TaskId.php diff --git a/src/Stubs/ExampleCommand.stub b/src/Stubs/ExampleCommand.stub new file mode 100644 index 0000000..0f7732b --- /dev/null +++ b/src/Stubs/ExampleCommand.stub @@ -0,0 +1,15 @@ + $value) { + if (array_key_exists($key, $defaults) && is_array($defaults[$key]) && is_array($value)) { + $defaults[$key] = self::deepMerge($defaults[$key], $value); + } else { + $defaults[$key] = $value; + } + } + + return $defaults; + } +} diff --git a/src/Support/Assets.php b/src/Support/Assets.php new file mode 100644 index 0000000..65f4b9c --- /dev/null +++ b/src/Support/Assets.php @@ -0,0 +1,41 @@ +codefy->make($binding); + + if (!$assets instanceof Assets) { + throw new RuntimeException( + message: sprintf("Assets group '%s' not found in the config file", $group) + ); + } + + return $assets; + } +} diff --git a/Support/BasePathDetector.php b/src/Support/BasePathDetector.php similarity index 100% rename from Support/BasePathDetector.php rename to src/Support/BasePathDetector.php diff --git a/Support/CodefyMailer.php b/src/Support/CodefyMailer.php similarity index 75% rename from Support/CodefyMailer.php rename to src/Support/CodefyMailer.php index b0b8ef5..02cbc8a 100644 --- a/Support/CodefyMailer.php +++ b/src/Support/CodefyMailer.php @@ -9,5 +9,5 @@ final class CodefyMailer extends QubusMailer { - public const VERSION = Application::APP_VERSION; + public const string VERSION = Application::APP_VERSION; } diff --git a/src/Support/CodefyServiceProvider.php b/src/Support/CodefyServiceProvider.php new file mode 100644 index 0000000..9c637e5 --- /dev/null +++ b/src/Support/CodefyServiceProvider.php @@ -0,0 +1,139 @@ +> */ + protected array $publishes = []; + + /** @var array> */ + protected array $publishGroups = []; + + public function __construct(protected Application $codefy) + { + parent::__construct($codefy); + } + + /** + * Register a booting callback to be run before the "boot" method is called. + * + * @param Closure $callback + * @return void + */ + public function booting(Closure $callback): void + { + $this->bootingCallbacks[] = $callback; + } + + /** + * Register a booted callback to be run after the "boot" method is called. + * + * @param Closure $callback + * @return void + */ + public function booted(Closure $callback): void + { + $this->bootedCallbacks[] = $callback; + } + + /** + * Call the registered booting callbacks. + * + * @return void + */ + public function callBootingCallbacks(): void + { + $index = 0; + + while ($index < count($this->bootingCallbacks)) { + $this->codefy->call($this->bootingCallbacks[$index]); + + $index++; + } + } + + /** + * Call the registered booted callbacks. + * + * @return void + */ + public function callBootedCallbacks(): void + { + $index = 0; + + while ($index < count($this->bootedCallbacks)) { + $this->codefy->call($this->bootedCallbacks[$index]); + + $index++; + } + } + + /** + * Get the default providers for a CodefyPHP application. + * + * @return DefaultProviders + */ + public static function defaultProviders(): DefaultProviders + { + return new DefaultProviders(); + } + + /** + * Register publishable paths for this provider. + * + * @param array $paths [from => tag] + * @param string|null $group Optional tag/group name ("config", "migrations", etc.) + */ + public function publishes(array $paths, ?string $group = null): void + { + foreach ($paths as $from => $tag) { + $tag = $group ?? $tag; // if group given, override + $this->publishes[$tag][$from] = $tag; + } + } + + /** + * Get all publishable paths for this provider. + * + * @param string|null $tag Restrict to a tag (e.g. "config", "migrations") + * @return array [from => tag] + */ + public function pathsToPublish(?string $tag = null): array + { + if ($tag !== null) { + return $this->publishes[$tag] ?? []; + } + + return array_merge(...array_values($this->publishes ?: [[]])); + } + + /** + * List all tags defined by this provider. + * + * @return array + */ + public function publishTags(): array + { + return array_keys($this->publishes); + } +} diff --git a/src/Support/DefaultCommands.php b/src/Support/DefaultCommands.php new file mode 100644 index 0000000..fa97703 --- /dev/null +++ b/src/Support/DefaultCommands.php @@ -0,0 +1,41 @@ +collection = $collection ?: [ + \Codefy\Framework\Console\Commands\MakeCommand::class, + \Codefy\Framework\Console\Commands\ScheduleRunCommand::class, + \Codefy\Framework\Console\Commands\PasswordHashCommand::class, + \Codefy\Framework\Console\Commands\InitCommand::class, + \Codefy\Framework\Console\Commands\MigrateStatusCommand::class, + \Codefy\Framework\Console\Commands\MigrateCheckCommand::class, + \Codefy\Framework\Console\Commands\MigrateGenerateCommand::class, + \Codefy\Framework\Console\Commands\MigrateUpCommand::class, + \Codefy\Framework\Console\Commands\MigrateDownCommand::class, + \Codefy\Framework\Console\Commands\MigrateCommand::class, + \Codefy\Framework\Console\Commands\MigrateRollbackCommand::class, + \Codefy\Framework\Console\Commands\MigrateRedoCommand::class, + \Codefy\Framework\Console\Commands\ScheduleListCommand::class, + \Codefy\Framework\Console\Commands\ServeCommand::class, + \Codefy\Framework\Console\Commands\UuidCommand::class, + \Codefy\Framework\Console\Commands\UlidCommand::class, + \Codefy\Framework\Console\Commands\FlushPipelineCommand::class, + \Codefy\Framework\Console\Commands\VendorPublishCommand::class, + \Codefy\Framework\Console\Commands\GenerateEncryptionKeyCommand::class, + \Codefy\Framework\Console\Commands\GenerateEncryptionKeyFileCommand::class, + \Codefy\Framework\Console\Commands\EncryptEnvCommand::class, + \Codefy\Framework\Console\Commands\QueueListCommand::class, + \Codefy\Framework\Console\Commands\QueueRunCommand::class, + ]; + } +} diff --git a/src/Support/DefaultProviders.php b/src/Support/DefaultProviders.php new file mode 100644 index 0000000..a59c3fd --- /dev/null +++ b/src/Support/DefaultProviders.php @@ -0,0 +1,23 @@ +collection = $collection ?: [ + \Codefy\Framework\Providers\AssetsServiceProvider::class, + \Codefy\Framework\Providers\EventDispatcherServiceProvider::class, + \Codefy\Framework\Providers\DatabaseConnectionServiceProvider::class, + \Codefy\Framework\Providers\PdoServiceProvider::class, + \Codefy\Framework\Providers\LocalizationServiceProvider::class, + ]; + } +} diff --git a/Support/LocalStorage.php b/src/Support/LocalStorage.php similarity index 100% rename from Support/LocalStorage.php rename to src/Support/LocalStorage.php diff --git a/Support/Password.php b/src/Support/Password.php similarity index 61% rename from Support/Password.php rename to src/Support/Password.php index 47e4eec..41ae521 100644 --- a/Support/Password.php +++ b/src/Support/Password.php @@ -7,7 +7,10 @@ use Qubus\Exception\Exception; use function defined; +use function password_algos; +use function password_get_info; use function password_hash; +use function password_needs_rehash; use function password_verify; use function Qubus\Security\Helpers\__observer; @@ -24,11 +27,12 @@ final class Password */ private static function algorithm(): string { - if (!defined(constant_name: 'PASSWORD_ARGON2ID')) { - $algo = PASSWORD_BCRYPT; - } else { + $algo = PASSWORD_BCRYPT; + + if (defined(constant_name: 'PASSWORD_ARGON2ID')) { $algo = PASSWORD_ARGON2ID; } + /** * Filters the password_hash() hashing algorithm. * @@ -45,6 +49,12 @@ private static function algorithm(): string */ private static function options(): array { + $options = ['memory_cost' => 1 << 12, 'time_cost' => 2, 'threads' => 2]; + + if (self::algorithm() === '2y') { + $options = ['cost' => 12]; + } + /** * Filters the password_hash() options parameter. * @@ -52,7 +62,7 @@ private static function options(): array */ return __observer()->filter->applyFilter( 'password.hash.options', - (array) ['memory_cost' => 1 << 12, 'time_cost' => 2, 'threads' => 2] + (array) $options ); } @@ -79,4 +89,38 @@ public static function verify(string $password, string $hash): bool { return password_verify($password, $hash); } + + /** + * Checks if the given hash matches the given algorithm and options provider. + * If not, it is assumed that the hash needs to be rehashed. + * + * @param string $hash + * @return bool + * @throws Exception + */ + public static function needsRehash(string $hash): bool + { + return password_needs_rehash(hash: $hash, algo: self::algorithm(), options: self::options()); + } + + /** + * Get available password hashing algorithm IDs. + * + * @return array + */ + public static function algos(): array + { + return password_algos(); + } + + /** + * Returns information about the given hash. + * + * @param string $password + * @return array + */ + public static function getInfo(string $password): array + { + return password_get_info(hash: $password); + } } diff --git a/Support/Paths.php b/src/Support/Paths.php similarity index 93% rename from Support/Paths.php rename to src/Support/Paths.php index c793910..30e00d6 100644 --- a/Support/Paths.php +++ b/src/Support/Paths.php @@ -21,7 +21,11 @@ */ final class Paths { - private array $paths = []; + //phpcs:disable + private array $paths = [] { + &get => $this->paths; + } + //phpcs:enable /** * @throws TypeException diff --git a/src/Support/RequestMethod.php b/src/Support/RequestMethod.php new file mode 100644 index 0000000..a04ad79 --- /dev/null +++ b/src/Support/RequestMethod.php @@ -0,0 +1,63 @@ +getHost(); + $url = add_trailing_slash($url); + + $url = concat_ws(string1: $url, string2: $path, separator: ''); + + return esc_url(url: $url); + } +} diff --git a/src/Support/StringParser.php b/src/Support/StringParser.php new file mode 100644 index 0000000..3966793 --- /dev/null +++ b/src/Support/StringParser.php @@ -0,0 +1,66 @@ +collection = array_merge($this->collection, $collection); + + return new static($this->collection); + } + + /** + * Replace the given collection with other collections. + * + * @param array $replacements + * @return static + */ + public function replace(array $replacements): static + { + $current = new Collection($this->collection); + + foreach ($replacements as $from => $to) { + $key = $current->search($from); + + $current = is_int($key) ? $current->replace([$key => $to]) : $current; + } + + return new static($current->values()->toArray()); + } + + /** + * Disable the given collection. + * + * @param array $collection + * @return static + */ + public function except(array $collection): static + { + return new static( + new Collection($this->collection) + ->reject(fn ($p) => in_array($p, $collection)) + ->values() + ->toArray() + ); + } + + /** + * Convert the collection to an array. + * + * @return array + */ + public function toArray(): array + { + return $this->collection; + } +} diff --git a/Support/Traits/ContainerAware.php b/src/Support/Traits/ContainerAware.php similarity index 85% rename from Support/Traits/ContainerAware.php rename to src/Support/Traits/ContainerAware.php index c8a59bb..27cb268 100644 --- a/Support/Traits/ContainerAware.php +++ b/src/Support/Traits/ContainerAware.php @@ -4,7 +4,7 @@ namespace Codefy\Framework\Support\Traits; -use Codefy\Framework\Codefy; +use Codefy\Framework\Proxy\Codefy; trait ContainerAware { diff --git a/Support/Traits/DbTransactionsAware.php b/src/Support/Traits/DbTransactionsAware.php similarity index 77% rename from Support/Traits/DbTransactionsAware.php rename to src/Support/Traits/DbTransactionsAware.php index 638be67..0b655e5 100644 --- a/Support/Traits/DbTransactionsAware.php +++ b/src/Support/Traits/DbTransactionsAware.php @@ -4,15 +4,19 @@ namespace Codefy\Framework\Support\Traits; -use Codefy\Framework\Codefy; +use Codefy\Framework\Proxy\Codefy; use Qubus\Exception\Exception; trait DbTransactionsAware { + //phpcs:disable /** * Determines whether class uses transaction. */ - protected bool $useTransaction = false; + protected bool $useTransaction = false { + get => $this->useTransaction; + } + //phpcs:enable /** * Enable transaction in pipeline. @@ -35,7 +39,7 @@ protected function beginTransaction(): void return; } - Codefy::$PHP->getDB()->beginTransaction(); + Codefy::$PHP->getDb()->beginTransaction(); } /** @@ -49,7 +53,7 @@ protected function commitTransaction(): void return; } - Codefy::$PHP->getDB()->commit(); + Codefy::$PHP->getDb()->commit(); } /** @@ -63,6 +67,6 @@ protected function rollbackTransaction(): void return; } - Codefy::$PHP->getDB()->rollback(); + Codefy::$PHP->getDb()->rollback(); } } diff --git a/src/Traits/LoggerAware.php b/src/Traits/LoggerAware.php new file mode 100644 index 0000000..0dd0b38 --- /dev/null +++ b/src/Traits/LoggerAware.php @@ -0,0 +1,35 @@ +fenom = (new Fenom( + $this->fenom = new Fenom( provider: new Provider(template_dir: $this->configContainer->getConfigKey(key: 'view.path')) - ))->setCompileDir( + )->setCompileDir( dir: $this->configContainer->getConfigKey(key: 'view.cache') )->setOptions(options: $this->configContainer->getConfigKey(key: 'view.options')); } diff --git a/View/FoilView.php b/src/View/FoilView.php similarity index 100% rename from View/FoilView.php rename to src/View/FoilView.php diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php new file mode 100644 index 0000000..463de0d --- /dev/null +++ b/tests/ApplicationTest.php @@ -0,0 +1,54 @@ +charset; + Assert::assertEquals(expected: 'UTF-8', actual: $charset); +}); + +it(description: 'sets charset value.', closure: function () { + $app = Application::getInstance(); + $app->charset = 'iso-8859-1'; + Assert::assertSame(expected: 'ISO-8859-1', actual: $app->charset); +}); + +it(description: 'gets default locale value.', closure: function () { + $locale = Application::getInstance()->locale; + Assert::assertEquals(expected: 'en', actual: $locale); +}); + +it(description: 'sets locale value.', closure: function () { + $app = Application::getInstance(); + $app->locale = 'es-ES'; + Assert::assertSame(expected: 'es-ES', actual: $app->locale); + + $app->withLocale(locale: 'es-ES'); + Assert::assertSame(expected: 'es-ES', actual: $app->locale); +}); + +it(description: 'gets default controller namespace value.', closure: function () { + $namespace = Application::getInstance()->controllerNamespace; + Assert::assertEquals(expected: 'App\\Infrastructure\\Http\\Controllers', actual: $namespace); +}); + +it(description: 'sets controller namespace value.', closure: function () { + $app = Application::getInstance(); + $app->controllerNamespace = 'Temp\\App\\Http\\Controllers'; + Assert::assertSame(expected: 'Temp\\App\\Http\\Controllers', actual: $app->controllerNamespace); + + $app->withControllerNamespace(namespace: 'Temp\\App\\Http\\Controllers'); + Assert::assertSame(expected: 'Temp\\App\\Http\\Controllers', actual: $app->controllerNamespace); +}); + +it(description: 'gets default booted value.', closure: function () { + $booted = Application::getInstance()->booted; + Assert::assertEquals(expected: false, actual: $booted); +}); + +it(description: 'sets booted value.', closure: function () { + $app = Application::getInstance(); + $app->setBooted(bool: true); + Assert::assertSame(expected: true, actual: $app->booted); +}); diff --git a/tests/Auth/PermissionTest.php b/tests/Auth/PermissionTest.php index 4a81b5d..11cca1c 100644 --- a/tests/Auth/PermissionTest.php +++ b/tests/Auth/PermissionTest.php @@ -6,35 +6,35 @@ $resource = Mockery::mock(\Codefy\Framework\Auth\Rbac\Resource\StorageResource::class); -it('should get the permission name.', function () use ($resource) { +it(description: 'should get the permission name.', closure: function () use ($resource) { $permission = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'admin:edit', + name: 'admin:edit', description: 'Edit permission.', rbacStorageCollection: $resource ); - Assert::assertEquals('admin:edit', $permission->getName()); + Assert::assertEquals('admin:edit', $permission->name); }); -it('should get permission description.', function () use ($resource) { +it(description: 'should get permission description.', closure: function () use ($resource) { $permission = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'admin:edit', + name: 'admin:edit', description: 'Edit permission.', rbacStorageCollection: $resource ); - Assert::assertEquals('Edit permission.', $permission->getDescription()); + Assert::assertEquals('Edit permission.', $permission->description); }); -it('should get children.', function () use ($resource) { +it(description: 'should get children.', closure: function () use ($resource) { $permission2 = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'perm:perm2', + name: 'perm:perm2', description: 'desc2', rbacStorageCollection: $resource ); $permission3 = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'perm:perm3', + name: 'perm:perm3', description: 'desc3', rbacStorageCollection: $resource ); @@ -43,7 +43,7 @@ $resource->shouldReceive('getPermission')->andReturn($permission2, $permission3); $permission1 = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'perm:perm1', + name: 'perm:perm1', description: 'desc1', rbacStorageCollection: $resource ); @@ -64,9 +64,9 @@ Assert::assertEquals(['perm:perm3' => $permission3], $permission1->getChildren()); }); -it('should set and get rule.', function () use ($resource) { +it(description: 'should set and get rule.', closure: function () use ($resource) { $permission = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'admin:edit', + name: 'admin:edit', description: 'Edit permission.', rbacStorageCollection: $resource ); diff --git a/tests/Auth/RbacTest.php b/tests/Auth/RbacTest.php index 1b11d90..65bdeee 100644 --- a/tests/Auth/RbacTest.php +++ b/tests/Auth/RbacTest.php @@ -5,35 +5,27 @@ use Codefy\Framework\Auth\Rbac\Entity\Permission; use Codefy\Framework\Auth\Rbac\Entity\Role; use Codefy\Framework\Auth\Rbac\Rbac; +use Codefy\Framework\Auth\Rbac\Resource\FileResource; use PHPUnit\Framework\Assert; -$resource = Mockery::mock(\Codefy\Framework\Auth\Rbac\Resource\StorageResource::class); +$resource = new FileResource(file: 'rbac.json'); it('should create instance.', function () use ($resource) { - $resource->shouldReceive('load'); - new Rbac($resource); - Assert::assertTrue(true); + Assert::assertTrue(condition: true); }); it('should create role and return Role instance.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('addRole') - ->with('role1', 'desc1') - ->andReturn(Mockery::mock(Role::class)); - $rbac = new Rbac($resource); - Assert::assertInstanceOf(Role::class, $rbac->addRole(name: 'role1', description: 'desc1')); + Assert::assertInstanceOf( + expected: Role::class, + actual: $rbac->addRole(name: 'admin', description: 'Administrator') + ); }); it('should create Permission and return Permission instance.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('addPermission') - ->with('dashboard:view', 'Access dashboard.') - ->andReturn(Mockery::mock(Permission::class)); - $rbac = new Rbac($resource); Assert::assertInstanceOf( @@ -42,44 +34,38 @@ ); }); -it('should get roles.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('getRoles') - ->andReturn([]); - +it('should get empty array for roles.', function () use ($resource) { $rbac = new Rbac($resource); - Assert::assertEquals([], $rbac->getRoles()); + Assert::assertEquals([], $rbac->roles); }); it('should get role.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('getRole') - ->with('role1') - ->andReturn(Mockery::mock(Role::class)); - $rbac = new Rbac($resource); - - Assert::assertInstanceOf(Role::class, $rbac->getRole('role1')); + $perm1 = $rbac->addPermission(name: 'create:post', description: 'Can create posts'); + $perm2 = $rbac->addPermission(name: 'moderate:post', description: 'Can moderate posts'); + $perm3 = $rbac->addPermission(name: 'update:post', description: 'Can update posts'); + $perm4 = $rbac->addPermission(name: 'delete:post', description: 'Can delete posts'); + $perm2->addChild($perm3); + $perm2->addChild($perm4); + + $adminRole = $rbac->addRole(name: 'admin'); + $moderatorRole = $rbac->addRole(name: 'moderator'); + $authorRole = $rbac->addRole(name: 'author'); + $adminRole->addChild($moderatorRole); + + Assert::assertInstanceOf(Role::class, $rbac->getRole(name: 'admin')); }); -it('should get permissions.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('getPermissions') - ->andReturn([]); - +it('should get empty array for permissions.', function () use ($resource) { $rbac = new Rbac($resource); - Assert::assertEquals([], $rbac->getPermissions()); + Assert::assertEquals(expected: [], actual: $rbac->permissions); }); it('should get permission.', function () use ($resource) { - $resource->shouldReceive('load'); - $resource->shouldReceive('getPermission') - ->with('perm1') - ->andReturn(Mockery::mock(Permission::class)); - $rbac = new Rbac($resource); + $perm1 = $rbac->addPermission(name: 'create:post', description: 'Can create posts'); - Assert::assertInstanceOf(Permission::class, $rbac->getPermission(name: 'perm1')); + Assert::assertInstanceOf(expected: Permission::class, actual: $rbac->getPermission(name: 'create:post')); }); diff --git a/tests/Auth/RoleTest.php b/tests/Auth/RoleTest.php index 477633f..821129f 100644 --- a/tests/Auth/RoleTest.php +++ b/tests/Auth/RoleTest.php @@ -8,33 +8,33 @@ it('should create Role instance.', function () use ($resource) { $role = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'admin', + name: 'admin', description: 'Super administrator.', rbacStorageCollection: $resource ); - Assert::assertEquals('admin', $role->getName()); + Assert::assertEquals('admin', $role->name); }); it('should get the role description', function () use ($resource) { $role = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'admin', + name: 'admin', description: 'Super administrator.', rbacStorageCollection: $resource ); - Assert::assertEquals('Super administrator.', $role->getDescription()); + Assert::assertEquals('Super administrator.', $role->description); }); it('should get children.', function () use ($resource) { $role2 = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'role2', + name: 'role2', description: 'desc2', rbacStorageCollection: $resource ); $role3 = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'role3', + name: 'role3', description: 'desc3', rbacStorageCollection: $resource ); @@ -43,7 +43,7 @@ $resource->shouldReceive('getRole')->andReturn($role2, $role3); $role1 = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'role1', + name: 'role1', description: 'desc1', rbacStorageCollection: $resource ); @@ -66,13 +66,13 @@ it('should get permissions.', function () use ($resource) { $permission2 = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'perm:perm2', + name: 'perm:perm2', description: 'desc2', rbacStorageCollection: $resource ); $permission3 = new \Codefy\Framework\Auth\Rbac\Entity\RbacPermission( - permissionName: 'perm:perm3', + name: 'perm:perm3', description: 'desc3', rbacStorageCollection: $resource ); @@ -81,7 +81,7 @@ $resource->shouldReceive('getPermission')->andReturn($permission2, $permission3); $role1 = new \Codefy\Framework\Auth\Rbac\Entity\RbacRole( - roleName: 'admin', + name: 'admin', description: 'Super administrator.', rbacStorageCollection: $resource ); diff --git a/tests/Auth/UserSessionTest.php b/tests/Auth/UserSessionTest.php new file mode 100644 index 0000000..1e15397 --- /dev/null +++ b/tests/Auth/UserSessionTest.php @@ -0,0 +1,35 @@ +token; + + Assert::assertSame(expected: null, actual: $token); +}); + +it('should throw error when setting token property.', function () { + $usession = new UserSession(); + $usession->token = 'test'; +})->throws(Error::class); + +it('should set token and return a UserSession object.', function () { + $usession = new UserSession(); + $usession->withToken(token: '426d9e8f-b8f7-4be0-a383-324ee3c3ea4d'); + + Assert::assertSame(expected: '426d9e8f-b8f7-4be0-a383-324ee3c3ea4d', actual: $usession->token); + Assert::assertInstanceOf(expected: UserSession::class, actual: $usession); + Assert::assertIsObject($usession); +}); + +it('should be an empty token after clearing.', function () { + $usession = new UserSession(); + $usession->withToken(token: '426d9e8f-b8f7-4be0-a383-324ee3c3ea4d'); + + Assert::assertFalse(condition: $usession->isEmpty()); + + $usession->clear(); + + Assert::assertTrue(condition: $usession->isEmpty()); +}); diff --git a/tests/Pipes/PipelineTest.php b/tests/Pipes/PipelineTest.php index 9dcde8b..3fddfbf 100644 --- a/tests/Pipes/PipelineTest.php +++ b/tests/Pipes/PipelineTest.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Codefy\Framework\Application; +use Codefy\Framework\Proxy\Codefy; use Codefy\Framework\Pipeline\Pipeline; -use Codefy\Framework\Pipeline\PipelineBuilder; use Codefy\Framework\tests\Pipes\PipeFour; use Codefy\Framework\tests\Pipes\PipeOne; use Codefy\Framework\tests\Pipes\PipeThree; @@ -86,8 +86,7 @@ }); it('accepts invokable class as pipe using PipelineBuilder.', function () { - $builder = (new PipelineBuilder()) - ->pipe(new PipeFour()); + $builder = Codefy::$PHP->pipeline->pipe(new PipeFour()); $pipeline = $builder->build(); diff --git a/tests/vendor/bootstrap.php b/tests/vendor/bootstrap.php index 51d7213..3d103d9 100644 --- a/tests/vendor/bootstrap.php +++ b/tests/vendor/bootstrap.php @@ -4,7 +4,8 @@ use Qubus\Exception\Data\TypeException; try { - return Application::configure(['basePath' => dirname(path: __DIR__, levels: 2)]) + return Application::create(['basePath' => dirname(path: __DIR__, levels: 2)]) + ->withKernels() ->withProviders([ // fill in custom providers ])