Working state but no uploads
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
8
src/Shared/Database/DatabaseNotProvisionedException.php
Normal file
8
src/Shared/Database/DatabaseNotProvisionedException.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Database;
|
||||
|
||||
final class DatabaseNotProvisionedException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
51
src/Shared/Database/DatabaseReadiness.php
Normal file
51
src/Shared/Database/DatabaseReadiness.php
Normal 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
127
src/Shared/Http/RequestContext.php
Normal file
127
src/Shared/Http/RequestContext.php
Normal 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]));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user