Working state but no uploads

This commit is contained in:
julien
2026-03-16 11:48:26 +01:00
parent e24ee5d622
commit 8e59daa4cd
21 changed files with 353 additions and 119 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
@@ -34,6 +35,7 @@ final class PasswordResetController
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
* @param FlashServiceInterface $flash Service de messages flash
* @param ClientIpResolver $clientIpResolver Résout l'IP réelle derrière un proxy approuvé
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
*/
public function __construct(
@@ -41,6 +43,7 @@ final class PasswordResetController
private readonly PasswordResetServiceInterface $passwordResetService,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
private readonly string $baseUrl,
) {
}
@@ -80,13 +83,7 @@ final class PasswordResetController
*/
public function forgot(Request $req, Response $res): Response
{
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx).
// Même logique que AuthController::login() — voir son commentaire pour le détail.
$serverParams = $req->getServerParams();
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
$ip = $forwarded !== '' && $forwarded !== '0.0.0.0'
? trim(explode(',', $forwarded)[0])
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
$ip = $this->clientIpResolver->resolve($req);
// Vérification du rate limit avant tout traitement
$remainingMinutes = $this->authService->checkRateLimit($ip);

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace App\Shared;
use App\Post\PostExtension;
use App\Shared\Database\Provisioner;
use App\Shared\Database\DatabaseNotProvisionedException;
use App\Shared\Database\DatabaseReadiness;
use App\Shared\Extension\AppExtension;
use App\Shared\Extension\CsrfExtension;
use App\Shared\Extension\SessionExtension;
@@ -14,6 +15,7 @@ use PDO;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Csrf\Guard;
@@ -40,7 +42,6 @@ final class Bootstrap
public function initialize(): App
{
$this->initializeInfrastructure();
$this->runAutoProvisioningIfEnabled();
return $this->createHttpApp();
}
@@ -138,21 +139,6 @@ final class Bootstrap
}
}
private function runAutoProvisioningIfEnabled(): void
{
$flag = strtolower(trim((string) ($_ENV['APP_AUTO_PROVISION'] ?? '')));
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$enabled = $flag !== ''
? in_array($flag, ['1', 'true', 'yes', 'on'], true)
: $isDev;
if (!$enabled) {
return;
}
Provisioner::run($this->container->get(PDO::class));
}
private function registerMiddlewares(): void
{
$this->app->addBodyParsingMiddleware();
@@ -168,6 +154,17 @@ final class Bootstrap
$guard->setPersistentTokenMode(true);
$twig->addExtension(new CsrfExtension($guard));
$this->app->add($guard);
$container = $this->container;
$this->app->add(function (
ServerRequestInterface $request,
RequestHandlerInterface $handler,
) use ($container): ResponseInterface {
DatabaseReadiness::assertProvisioned($container->get(PDO::class));
return $handler->handle($request);
});
}
private function registerRoutes(): void
@@ -180,6 +177,8 @@ final class Bootstrap
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$logger = $this->container->get(LoggerInterface::class);
$errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger);
$app = $this->app;
$container = $this->container;
$errorHandler->setDefaultErrorHandler(
function (
@@ -188,24 +187,30 @@ final class Bootstrap
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
) use ($isDev): ResponseInterface {
if ($isDev) {
) use ($isDev, $app, $container): ResponseInterface {
if ($isDev && !$exception instanceof DatabaseNotProvisionedException) {
throw $exception;
}
$statusCode = 500;
if ($exception instanceof HttpException) {
$message = 'Une erreur inattendue s\'est produite.';
if ($exception instanceof DatabaseNotProvisionedException) {
$statusCode = 503;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpException) {
$statusCode = $exception->getCode() ?: 500;
$message = $statusCode === 404
? 'La page demandée est introuvable.'
: $message;
}
$response = $this->app->getResponseFactory()->createResponse($statusCode);
$twig = $this->container->get(Twig::class);
$response = $app->getResponseFactory()->createResponse($statusCode);
$twig = $container->get(Twig::class);
return $twig->render($response, 'pages/error.twig', [
'status' => $statusCode,
'message' => $statusCode === 404
? 'La page demandée est introuvable.'
: 'Une erreur inattendue s\'est produite.',
'message' => $message,
]);
}
);

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
final class DatabaseNotProvisionedException extends \RuntimeException
{
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Vérifie que le schéma minimal existe avant de servir des requêtes HTTP.
*
* Le runtime web ne provisionne pas automatiquement la base : les migrations
* et le seed initial doivent être exécutés explicitement via `php bin/provision.php`.
*/
final class DatabaseReadiness
{
/**
* @var string[]
*/
private const REQUIRED_TABLES = ['migrations', 'users', 'posts'];
public static function assertProvisioned(PDO $db): void
{
$existingTables = self::existingTables($db);
$missingTables = array_values(array_diff(self::REQUIRED_TABLES, $existingTables));
if ($missingTables === []) {
return;
}
throw new DatabaseNotProvisionedException(
sprintf(
"Base de donnees non provisionnee : tables manquantes (%s). Executez 'php bin/provision.php' avant de servir l'application.",
implode(', ', $missingTables)
)
);
}
/**
* @return string[]
*/
private static function existingTables(PDO $db): array
{
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
$rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
return array_values(array_filter(array_map(
static fn (mixed $value): string => (string) $value,
$rows
)));
}
}

View File

@@ -24,8 +24,8 @@ final class Seeder
/**
* Exécute toutes les opérations de provisionnement.
*
* Appelé dans Bootstrap::initialize() après Migrator::run(), une fois
* que le schéma est garanti à jour.
* Appelé par le script de provisionnement après Migrator::run(),
* une fois que le schéma est garanti à jour.
*
* @param PDO $db L'instance de connexion à la base de données
*/

View File

@@ -23,36 +23,6 @@ final class ClientIpResolver
public function resolve(ServerRequestInterface $request): string
{
$serverParams = $request->getServerParams();
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '') {
return '0.0.0.0';
}
if (!$this->isTrustedProxy($remoteAddr)) {
return $remoteAddr;
}
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded === '') {
return $remoteAddr;
}
$candidate = trim(explode(',', $forwarded)[0]);
return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remoteAddr;
}
private function isTrustedProxy(string $remoteAddr): bool
{
foreach ($this->trustedProxies as $proxy) {
if ($proxy === '*' || $proxy === $remoteAddr) {
return true;
}
}
return false;
return RequestContext::resolveClientIp($request->getServerParams(), $this->trustedProxies);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Helpers liés au contexte HTTP courant (IP cliente, HTTPS, proxies de confiance).
*
* Les en-têtes X-Forwarded-* ne sont pris en compte que si REMOTE_ADDR
* correspond à un proxy explicitement approuvé.
*/
final class RequestContext
{
/**
* @param array<string, mixed> $serverParams
* @param string[] $trustedProxies
*/
public static function resolveClientIp(array $serverParams, array $trustedProxies = []): string
{
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '') {
return '0.0.0.0';
}
if (!self::isTrustedProxy($remoteAddr, $trustedProxies)) {
return $remoteAddr;
}
$forwarded = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_FOR');
return $forwarded !== null && filter_var($forwarded, FILTER_VALIDATE_IP)
? $forwarded
: $remoteAddr;
}
/**
* @param array<string, mixed> $serverParams
* @param string[] $trustedProxies
*/
public static function isHttps(array $serverParams, array $trustedProxies = []): bool
{
$https = strtolower(trim((string) ($serverParams['HTTPS'] ?? '')));
if ($https !== '' && $https !== 'off' && $https !== '0') {
return true;
}
$scheme = strtolower(trim((string) ($serverParams['REQUEST_SCHEME'] ?? '')));
if ($scheme === 'https') {
return true;
}
if ((string) ($serverParams['SERVER_PORT'] ?? '') === '443') {
return true;
}
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '' || !self::isTrustedProxy($remoteAddr, $trustedProxies)) {
return false;
}
$forwardedProto = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_PROTO');
return $forwardedProto === 'https';
}
/**
* @param array<string, mixed> $environment
* @param array<string, mixed> $serverParams
* @return string[]
*/
public static function trustedProxiesFromEnvironment(array $environment = [], array $serverParams = []): array
{
$candidates = [
$environment['TRUSTED_PROXIES'] ?? null,
$serverParams['TRUSTED_PROXIES'] ?? null,
getenv('TRUSTED_PROXIES') ?: null,
];
foreach ($candidates as $candidate) {
if ($candidate === null) {
continue;
}
$raw = trim((string) $candidate);
if ($raw === '') {
continue;
}
return array_values(array_filter(array_map(
static fn (string $value): string => trim($value),
explode(',', $raw)
), static fn (string $value): bool => $value !== ''));
}
return [];
}
/**
* @param string[] $trustedProxies
*/
private static function isTrustedProxy(string $remoteAddr, array $trustedProxies): bool
{
foreach ($trustedProxies as $proxy) {
if ($proxy === '*' || $proxy === $remoteAddr) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $serverParams
*/
private static function firstForwardedValue(array $serverParams, string $header): ?string
{
$raw = trim((string) ($serverParams[$header] ?? ''));
if ($raw === '') {
return null;
}
return strtolower(trim(explode(',', $raw)[0]));
}
}

View File

@@ -95,10 +95,12 @@ final class SessionManager implements SessionManagerInterface
$sessionName = session_name();
if ($sessionName !== false) {
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
setcookie($sessionName, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
'httponly' => true,
'samesite' => 'Lax',
]);