This commit is contained in:
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: PHP Composer
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ '*' ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ '*' ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
|
- name: Validate composer.json
|
||||||
|
run: composer validate
|
||||||
|
|
||||||
|
- name: Cache Composer packages
|
||||||
|
id: composer-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: vendor
|
||||||
|
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-php-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.composer-cache.outputs.cache-hit != 'true'
|
||||||
|
run: composer install --prefer-dist --no-progress --no-suggest
|
||||||
|
|
||||||
|
- name: Run test suite
|
||||||
|
run: composer run-script test
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/vendor/
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
coverage.xml
|
||||||
|
.env
|
||||||
|
resources/docker/db-data
|
||||||
29
.php-cs-fixer.dist.php
Normal file
29
.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return (new PhpCsFixer\Config())
|
||||||
|
->setRules([
|
||||||
|
'@Symfony' => true,
|
||||||
|
'@Symfony:risky' => true,
|
||||||
|
'@PSR12' => true,
|
||||||
|
'@PHP81Migration' => true,
|
||||||
|
'@PHP80Migration:risky' => true,
|
||||||
|
'@PHPUnit84Migration:risky' => true,
|
||||||
|
'ordered_imports' => true,
|
||||||
|
'phpdoc_line_span' => [
|
||||||
|
'const' => 'single',
|
||||||
|
'method' => 'single',
|
||||||
|
'property' => 'single',
|
||||||
|
],
|
||||||
|
'php_unit_test_case_static_method_calls' => true,
|
||||||
|
'phpdoc_to_comment' => false,
|
||||||
|
])
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setFinder(
|
||||||
|
PhpCsFixer\Finder::create()
|
||||||
|
->in([
|
||||||
|
__DIR__,
|
||||||
|
])
|
||||||
|
->ignoreUnreadableDirs()
|
||||||
|
);
|
||||||
15
Makefile
Normal file
15
Makefile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
EXEC="docker compose exec php"
|
||||||
|
|
||||||
|
up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
start: up composer-install
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose stop
|
||||||
|
cmd:
|
||||||
|
"$(EXEC)" sh
|
||||||
|
phpstan:
|
||||||
|
"$(EXEC)" vendor/bin/phpstan analyse src --level 8
|
||||||
|
cs-fix:
|
||||||
|
"$(EXEC)" vendor/bin/php-cs-fixer fix --allow-risky=yes
|
||||||
53
composer.json
Normal file
53
composer.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "armelnl/armelnet",
|
||||||
|
"description": "Armel.nl website",
|
||||||
|
"license": "WTFPL",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/Utility/Helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"laminas/laminas-diactoros": "^3.3",
|
||||||
|
"laminas/laminas-httphandlerrunner": "^2.10",
|
||||||
|
"php-di/php-di": "^7.0",
|
||||||
|
"league/plates": "^3.5",
|
||||||
|
"filp/whoops": "^2.15",
|
||||||
|
"vlucas/phpdotenv": "^5.6",
|
||||||
|
"league/route": "^5.1",
|
||||||
|
"symfony/var-dumper": "^7.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.63",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"rregeer/phpunit-coverage-check": "^0.3.1",
|
||||||
|
"pestphp/pest": "^2.35"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"php-cs-fixer": [
|
||||||
|
"vendor/bin/php-cs-fixer fix"
|
||||||
|
],
|
||||||
|
"phpstan": [
|
||||||
|
"@php vendor/bin/phpstan analyse --memory-limit=-1"
|
||||||
|
],
|
||||||
|
"check-coverage": [
|
||||||
|
"@php vendor/bin/coverage-check coverage.xml 100"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"composer phpstan",
|
||||||
|
"composer php-cs-fixer",
|
||||||
|
"@php vendor/bin/pest --coverage-clover=coverage.xml",
|
||||||
|
"composer check-coverage"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6443
composer.lock
generated
Normal file
6443
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
config/di.php
Normal file
61
config/di.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\EnvironmentLoader;
|
||||||
|
use App\Enums\Environment;
|
||||||
|
use App\Http\Middleware\CspMiddleware;
|
||||||
|
use App\Http\Middleware\HstsMiddleware;
|
||||||
|
use App\Http\Middleware\StartSessionMiddleware;
|
||||||
|
use App\Http\Routes;
|
||||||
|
use App\Security\Csp\CspPolicy;
|
||||||
|
use App\Security\Csp\Directives\DefaultSrc;
|
||||||
|
use App\Security\Csp\Directives\ImgSrc;
|
||||||
|
use App\Security\Csp\Directives\ScriptSrc;
|
||||||
|
use App\Security\Csp\Directives\StyleSrc;
|
||||||
|
use App\Security\Hst\Config as HstConfig;
|
||||||
|
use Laminas\Diactoros\ServerRequest;
|
||||||
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
|
use League\Plates\Engine;
|
||||||
|
use League\Route\Router;
|
||||||
|
use League\Route\Strategy\ApplicationStrategy;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Environment::class => static fn (): Environment => Environment::from($_ENV['ENVIRONMENT']),
|
||||||
|
ServerRequest::class => static fn (): ServerRequest => ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES),
|
||||||
|
Engine::class => static fn (): Engine => new Engine(dirname(__DIR__).'/resources/views'),
|
||||||
|
EnvironmentLoader::class => static fn () => new EnvironmentLoader(dirname(__DIR__)),
|
||||||
|
'middlewares' => static fn () => [
|
||||||
|
StartSessionMiddleware::class,
|
||||||
|
CspMiddleware::class,
|
||||||
|
HstsMiddleware::class,
|
||||||
|
],
|
||||||
|
Router::class => static function (ContainerInterface $container): Router {
|
||||||
|
$strategy = new ApplicationStrategy();
|
||||||
|
$strategy->setContainer($container);
|
||||||
|
|
||||||
|
$router = new Router();
|
||||||
|
$router->setStrategy($strategy);
|
||||||
|
|
||||||
|
$router->lazyMiddlewares($container->get('middlewares'));
|
||||||
|
|
||||||
|
Routes::routes($router);
|
||||||
|
|
||||||
|
return $router;
|
||||||
|
},
|
||||||
|
CspPolicy::class => static fn (ContainerInterface $container): CspPolicy => (new CspPolicy())
|
||||||
|
->addDirective($container->get(DefaultSrc::class))
|
||||||
|
->addDirective($container->get(ImgSrc::class))
|
||||||
|
->addDirective($container->get(ScriptSrc::class))
|
||||||
|
->addDirective($container->get(StyleSrc::class)),
|
||||||
|
DefaultSrc::class => static fn (): DefaultSrc => (new DefaultSrc())->addSource("'self'"),
|
||||||
|
ImgSrc::class => static fn (): ImgSrc => (new ImgSrc())->addSource("'self'"),
|
||||||
|
ScriptSrc::class => static fn (): ScriptSrc => (new ScriptSrc())->addSource("'self'"),
|
||||||
|
StyleSrc::class => static fn (): StyleSrc => (new StyleSrc())->addSource("'self'"),
|
||||||
|
HstConfig::class => static fn (): HstConfig => new HstConfig(
|
||||||
|
maxAge: 31536000,
|
||||||
|
includeSubdomains: true,
|
||||||
|
preload: true
|
||||||
|
),
|
||||||
|
];
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: resources/docker/php
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
pgsql:
|
||||||
|
image: postgres:13-alpine
|
||||||
|
volumes:
|
||||||
|
- ./resources/docker/db-data:/var/lib/postgresql/data:delegated
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=app
|
||||||
|
- POSTGRES_USER=app
|
||||||
|
- POSTGRES_PASSWORD=secret
|
||||||
4
phpstan.neon
Normal file
4
phpstan.neon
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
parameters:
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
level: 8
|
||||||
17
phpunit.xml
Normal file
17
phpunit.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Test Suite">
|
||||||
|
<directory suffix="Test.php">./tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">./src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
9
public/index.php
Normal file
9
public/index.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Kernel;
|
||||||
|
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
app(Kernel::class)->run();
|
||||||
13
resources/docker/php/Dockerfile
Normal file
13
resources/docker/php/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM composer:latest as composer
|
||||||
|
FROM dunglas/frankenphp:php8.3.10-alpine
|
||||||
|
|
||||||
|
ARG PUID=1000
|
||||||
|
ARG PGID=1000
|
||||||
|
|
||||||
|
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
RUN install-php-extensions \
|
||||||
|
opcache \
|
||||||
|
intl \
|
||||||
|
pdo_pgsql \
|
||||||
|
pcov
|
||||||
1
resources/views/error.php
Normal file
1
resources/views/error.php
Normal file
@@ -0,0 +1 @@
|
|||||||
|
HTTP <?php echo $status; ?> <?php echo $message; ?>
|
||||||
1
resources/views/home.php
Normal file
1
resources/views/home.php
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welkom
|
||||||
30
src/Core/Container.php
Normal file
30
src/Core/Container.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Core/EnvironmentLoader.php
Normal file
41
src/Core/EnvironmentLoader.php
Normal 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
23
src/Enums/Environment.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Http/Controllers/HomeController.php
Normal file
17
src/Http/Controllers/HomeController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Http/ExceptionHandler.php
Normal file
33
src/Http/ExceptionHandler.php
Normal 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
48
src/Http/Kernel.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Http/Middleware/CspMiddleware.php
Normal file
26
src/Http/Middleware/CspMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Http/Middleware/HstsMiddleware.php
Normal file
26
src/Http/Middleware/HstsMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Http/Middleware/StartSessionMiddleware.php
Normal file
22
src/Http/Middleware/StartSessionMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Http/Response/View.php
Normal file
11
src/Http/Response/View.php
Normal 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
16
src/Http/Routes.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Security/Csp/CspPolicy.php
Normal file
23
src/Security/Csp/CspPolicy.php
Normal 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).';';
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Security/Csp/Directive.php
Normal file
25
src/Security/Csp/Directive.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Security/Csp/Directives/DefaultSrc.php
Normal file
15
src/Security/Csp/Directives/DefaultSrc.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Security/Csp/Directives/ImgSrc.php
Normal file
15
src/Security/Csp/Directives/ImgSrc.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Security/Csp/Directives/ScriptSrc.php
Normal file
15
src/Security/Csp/Directives/ScriptSrc.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Security/Csp/Directives/StyleSrc.php
Normal file
15
src/Security/Csp/Directives/StyleSrc.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Security/Hst/Config.php
Normal file
25
src/Security/Hst/Config.php
Normal 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
40
src/Utility/Helpers.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/Feature/HomeControllerTest.php
Normal file
19
tests/Feature/HomeControllerTest.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('returns status 200', function (): void {
|
||||||
|
$response = get('/');
|
||||||
|
$body = $response->getBody();
|
||||||
|
$body->rewind();
|
||||||
|
|
||||||
|
expect($response->getStatusCode())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('view is rendered', function (): void {
|
||||||
|
$response = get('/');
|
||||||
|
$body = $response->getBody();
|
||||||
|
$body->rewind();
|
||||||
|
|
||||||
|
expect($body->getContents())->toBe('Welkom');
|
||||||
|
});
|
||||||
15
tests/Pest.php
Normal file
15
tests/Pest.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Laminas\Diactoros\ServerRequest;
|
||||||
|
use League\Route\Router;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
function get(string $uri, array $headers = [], array $cookies = []): ResponseInterface
|
||||||
|
{
|
||||||
|
$router = app(Router::class);
|
||||||
|
$request = new ServerRequest(uri: $uri, method: 'GET', headers: $headers, cookieParams: $cookies);
|
||||||
|
|
||||||
|
return $router->dispatch($request);
|
||||||
|
}
|
||||||
11
tests/TestCase.php
Normal file
11
tests/TestCase.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase as BaseTestCase;
|
||||||
|
|
||||||
|
abstract class TestCase extends BaseTestCase
|
||||||
|
{
|
||||||
|
}
|
||||||
7
tests/Unit/ExampleTest.php
Normal file
7
tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
test('example', function (): void {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user