build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers
Implements MeshVault M1 (issues #6–11): - Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets) - Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling - Immutable validated AppConfig with redacted diagnostics - JSON stdout Monolog logging with job-scoped context - Tailwind CSS v4 deterministic locked builds with template scanning - POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch) - Startup config validation fails before FPM serves as unprivileged www-data - migrate/seed explicitly unavailable until M2 (#12); #10 remains open Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
1 parent
c2747a5762
commit
d3e8cf8fae
39 files changed
+5291
-38
No files matched your search
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use DI\ContainerBuilder;
|
||||
use MeshVault\Config\AppConfig;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\App;
|
||||
use Slim\Exception\HttpMethodNotAllowedException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Psr7\Response;
|
||||
use Throwable;
|
||||
|
||||
final class AppFactory
|
||||
{
|
||||
public static function create(AppConfig $config): App
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
|
||||
$builder->addDefinitions($definitions($config));
|
||||
$container = $builder->build();
|
||||
$app = \Slim\Factory\AppFactory::createFromContainer($container);
|
||||
$routes = require dirname(__DIR__, 2) . '/config/routes.php';
|
||||
$routes($app);
|
||||
$app->addRoutingMiddleware();
|
||||
$errors = $app->addErrorMiddleware($config->environment === 'development', true, false);
|
||||
$errors->setDefaultErrorHandler(function (
|
||||
ServerRequestInterface $request,
|
||||
Throwable $exception,
|
||||
bool $displayErrorDetails
|
||||
) use ($container): Response {
|
||||
$status = match (true) {
|
||||
$exception instanceof HttpNotFoundException => 404,
|
||||
$exception instanceof HttpMethodNotAllowedException => 405,
|
||||
default => 500,
|
||||
};
|
||||
$title = match ($status) {
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
default => 'Internal Server Error',
|
||||
};
|
||||
if ($status === 500) {
|
||||
// No exception messages, arguments, request data or config values in production logs.
|
||||
$container->get(LoggerInterface::class)->error('HTTP request failed', [
|
||||
'exception_type' => $exception::class,
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
$response = new Response($status);
|
||||
if ($exception instanceof HttpMethodNotAllowedException) {
|
||||
$response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods()));
|
||||
}
|
||||
$response->getBody()->write($container->get(View::class)->render('error', [
|
||||
'title' => $title,
|
||||
'detail' => $displayErrorDetails ? $exception::class . ': ' . $exception->getMessage() : '',
|
||||
]));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
});
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final readonly class HomeHandler
|
||||
{
|
||||
public function __construct(private View $view)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write($this->view->render('home', ['title' => 'MeshVault']));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
final readonly class View
|
||||
{
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
public function render(string $template, array $data): string
|
||||
{
|
||||
if (!in_array($template, ['home', 'error'], true)) {
|
||||
throw new \InvalidArgumentException('Unknown view.');
|
||||
}
|
||||
$escape = static fn (string $value): string => htmlspecialchars(
|
||||
$value,
|
||||
ENT_QUOTES | ENT_SUBSTITUTE,
|
||||
'UTF-8'
|
||||
);
|
||||
ob_start();
|
||||
try {
|
||||
require $this->directory . '/' . $template . '.php';
|
||||
return (string) ob_get_contents();
|
||||
} finally {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user