Initial commit
All checks were successful
PHP Composer / build (push) Successful in 10m28s

This commit is contained in:
Armel van Ravels
2024-08-30 20:29:39 +02:00
commit bb1d280731
38 changed files with 7226 additions and 0 deletions

30
src/Core/Container.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Core;
use DI\Container as DIContainer;
use DI\ContainerBuilder;
/** @codeCoverageIgnore */
final class Container
{
private function __construct()
{
}
public static ?DIContainer $container = null;
public static function getInstance(): DIContainer
{
if (null === self::$container) {
$container = (new ContainerBuilder())
->addDefinitions(__DIR__.'/../../config/di.php');
self::$container = $container->build();
}
return self::$container;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Enums\Environment;
use Dotenv\Dotenv;
/** @codeCoverageIgnore */
final class EnvironmentLoader
{
public function __construct(private readonly string $rootDir)
{
}
public function load(?Environment $environment = null): void
{
$filename = $this->determineFileName($environment);
if (!file_exists($this->rootDir.'/'.$filename)) {
throw new \RuntimeException(\sprintf('A %s file is necessary to run this application', $filename));
}
$dotenv = Dotenv::createImmutable($this->rootDir, $filename);
$dotenv->load();
$dotenv
->required('ENVIRONMENT')
->allowedValues(array_map(static fn (Environment $environment): string => $environment->value, Environment::cases()));
}
private function determineFileName(?Environment $environment = null): string
{
return match ($environment) {
Environment::Testing => '.env.testing',
default => '.env',
};
}
}

23
src/Enums/Environment.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Enums;
/** @codeCoverageIgnore */
enum Environment: string
{
case Development = 'development';
case Production = 'production';
case Testing = 'testing';
public function isCurrent(): bool
{
return $this->value === $_ENV['ENVIRONMENT'];
}
public static function current(): self
{
return self::from($_ENV['ENVIRONMENT']);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Response\View;
use Psr\Http\Message\ServerRequestInterface;
final class HomeController
{
/** @param array<string, mixed> $args */
public function __invoke(ServerRequestInterface $request, array $args = []): View
{
return view('home');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use League\Route\Http\Exception;
/** @codeCoverageIgnore */
final class ExceptionHandler
{
public function __construct(
private readonly SapiEmitter $emitter
) {
}
public function handle(\Throwable $error): void
{
$this
->emitter
->emit(
view(
'error',
[
'status' => $statusCode = $error instanceof Exception ? $error->getStatusCode() : 500,
'message' => $error instanceof Exception ? $error->getMessage() : 'Internal Server Error',
],
)
->withStatus($statusCode),
);
}
}

48
src/Http/Kernel.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http;
use App\Core\EnvironmentLoader;
use App\Enums\Environment;
use Laminas\Diactoros\ServerRequest;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use League\Route\Router;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;
/** @codeCoverageIgnore */
final readonly class Kernel
{
public function __construct(
private EnvironmentLoader $environmentLoader,
private ExceptionHandler $exceptionHandler,
private ServerRequest $serverRequest,
private Router $router,
private SapiEmitter $sapiEmitter,
) {
}
public function run(): void
{
$this->environmentLoader->load();
$this->setExceptionHandler();
$response = $this->router->dispatch($this->serverRequest);
$this->sapiEmitter->emit($response);
}
private function setExceptionHandler(): void
{
if (Environment::Production->isCurrent()) {
set_exception_handler($this->exceptionHandler->handle(...));
return;
}
$whoops = new Run();
$whoops->pushHandler(new PrettyPageHandler());
$whoops->register();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Security\Csp\CspPolicy;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class CspMiddleware implements MiddlewareInterface
{
public function __construct(
private CspPolicy $cspPolicy
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
return $handler
->handle($request)
->withHeader('Content-Security-Policy', (string) $this->cspPolicy);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Security\Hst\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class HstsMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Config $hstsConfig
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
return $handler
->handle($request)
->withHeader('Strict-Transport-Security', (string) $this->hstsConfig);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class StartSessionMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (\PHP_SESSION_NONE === session_status()) {
session_start();
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Http\Response;
use Laminas\Diactoros\Response;
final class View extends Response
{
}

16
src/Http/Routes.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Http;
use App\Http\Controllers\HomeController;
use League\Route\Router;
final class Routes
{
public static function routes(Router $route): void
{
$route->get('/', HomeController::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp;
final class CspPolicy
{
/** @var array<string, Directive> */
private array $directives = [];
public function addDirective(Directive $directive): self
{
$this->directives[$directive->getName()] = $directive;
return $this;
}
public function __toString(): string
{
return implode('; ', $this->directives).';';
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp;
abstract class Directive
{
/** @var string[] */
private array $sources = [];
abstract public function getName(): string;
public function addSource(string $source): self
{
$this->sources[] = $source;
return $this;
}
public function __toString(): string
{
return $this->getName().' '.implode(' ', $this->sources);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp\Directives;
use App\Security\Csp\Directive;
final class DefaultSrc extends Directive
{
public function getName(): string
{
return 'default-src';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp\Directives;
use App\Security\Csp\Directive;
final class ImgSrc extends Directive
{
public function getName(): string
{
return 'img-src';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp\Directives;
use App\Security\Csp\Directive;
final class ScriptSrc extends Directive
{
public function getName(): string
{
return 'script-src';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Security\Csp\Directives;
use App\Security\Csp\Directive;
final class StyleSrc extends Directive
{
public function getName(): string
{
return 'style-src';
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Security\Hst;
final class Config
{
public function __construct(
public readonly int $maxAge,
public readonly bool $includeSubdomains,
public readonly bool $preload,
) {
}
public function __toString(): string
{
return \sprintf(
'max-age=%s%s%s',
$this->maxAge,
$this->includeSubdomains ? '; includeSubdomains' : '',
$this->preload ? '; preload' : '',
);
}
}

40
src/Utility/Helpers.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Core\Container;
use App\Http\Response\View;
use League\Plates\Engine;
if (!function_exists('app')) { // @codeCoverageIgnore
function app(?string $className = null): mixed
{
return null === $className ? Container::getInstance() : Container::getInstance()->get($className);
}
}
if (!function_exists('view')) { // @codeCoverageIgnore
/**
* @param mixed[] $args
*/
function view(string $template = '', array $args = []): View
{
/** @var View $response */
$response = app(View::class);
if ('' === $template) {
return $response; // @codeCoverageIgnore
}
/** @var Engine $engine */
$engine = app(Engine::class);
$response
->getBody()
->write(
$engine->render($template, $args),
);
return $response;
}
}