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

40
.gitea/workflows/ci.yml Normal file
View 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
View 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
View 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
View 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

0
README.md Normal file
View File

53
composer.json Normal file
View 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

File diff suppressed because it is too large Load Diff

61
config/di.php Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
parameters:
paths:
- src
level: 8

17
phpunit.xml Normal file
View 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
View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use App\Http\Kernel;
require __DIR__.'/../vendor/autoload.php';
app(Kernel::class)->run();

View 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

View File

@@ -0,0 +1 @@
HTTP <?php echo $status; ?> <?php echo $message; ?>

1
resources/views/home.php Normal file
View File

@@ -0,0 +1 @@
Welkom

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;
}
}

View 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
View 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
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
test('example', function (): void {
expect(true)->toBeTrue();
});