Server: portable rewrite + JSON-only error handler

The previous .htaccess used PATH_INFO (api/index.php/$1) which OVH mutualisé
does not always allow, producing an Apache 500 HTML page that masked our own
JSON error reporting.

Switch to query-string routing (?route=...): same effect, works everywhere.
Add a global exception handler in index.php that emits JSON errors only —
no more opaque Apache 500.

Add a /api/debug endpoint that reports PHP version, sodium availability,
pdo_mysql, and whether versions.json is found. Useful for diagnosing
shared-hosting setup before adding license logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 09:07:22 +02:00
parent 1c8c6803e8
commit c501115612
2 changed files with 68 additions and 56 deletions

View File

@@ -1,28 +1,15 @@
RewriteEngine On RewriteEngine On
RewriteBase /PS_Launcher/ RewriteBase /PS_Launcher/
# HTTPS forcé # HTTPS forcé (si le mutualisé OVH ne le force pas déjà)
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Pas de listing de répertoires # Pas de listing de répertoires
Options -Indexes Options -Indexes
# /PS_Launcher/api/* → api/index.php (le PATH_INFO est exposé via QUERY_STRING) # /PS_Launcher/api -> api/index.php
# /PS_Launcher/api/foo -> api/index.php?route=foo
# (passage par query string : pas de PATH_INFO, marche partout)
RewriteRule ^api/?$ api/index.php [QSA,L] RewriteRule ^api/?$ api/index.php [QSA,L]
RewriteRule ^api/(.*)$ api/index.php/$1 [QSA,L] RewriteRule ^api/([^?]+)/?$ api/index.php?route=$1 [QSA,L]
# Bloquer l'accès direct aux fichiers sensibles
<FilesMatch "\.(sql|md\.bak|log|env)$">
Require all denied
</FilesMatch>
# Headers de sécurité
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set Referrer-Policy "no-referrer"
</IfModule>
# Type MIME pour les ZIPs (assure la pris en compte de Range:)
AddType application/zip .zip

View File

@@ -1,52 +1,77 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
// Erreurs en JSON, jamais en HTML — pour ne plus avoir d'écran Apache 500 opaque.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) return false;
throw new ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
error_log('[PSLauncher] ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
echo json_encode([
'error' => 'server_error',
'message' => $e->getMessage(),
'file' => basename($e->getFile()),
'line' => $e->getLine(),
]);
exit;
});
require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/Response.php';
use PSLauncher\Response; use PSLauncher\Response;
// Charge la config (hors git ; copier config.example.php → config.php au déploiement) // Charge la config (config.example.php est le template ; config.php est gitignored)
$configPath = __DIR__ . '/config.php'; $configPath = __DIR__ . '/config.php';
if (!is_file($configPath)) { if (!is_file($configPath)) {
http_response_code(500); Response::error('config_missing', 'config.php absent — copier config.example.php', 500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'config_missing', 'message' => 'config.php absent — copier config.example.php']);
exit;
} }
$config = require $configPath; $config = require $configPath;
// Le path est passé via REQUEST_URI parce que le RewriteRule utilise PATH_INFO // La route est passée via ?route=... par le .htaccess
$uri = $_SERVER['REQUEST_URI'] ?? '/'; $route = trim((string)($_GET['route'] ?? ''), '/');
$path = parse_url($uri, PHP_URL_PATH) ?? '/';
// Strip prefix /PS_Launcher/api
if (preg_match('#^.*?/PS_Launcher/api(.*)$#', $path, $m)) {
$route = trim($m[1], '/');
} else {
$route = trim($path, '/');
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
try { if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
require __DIR__ . '/routes/Manifest.php'; require __DIR__ . '/routes/Manifest.php';
\PSLauncher\Routes\Manifest::handle($config); \PSLauncher\Routes\Manifest::handle($config);
return; return;
} }
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) { if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
require __DIR__ . '/routes/Releasenotes.php'; require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]); \PSLauncher\Routes\Releasenotes::handle($m[1]);
return; return;
}
if ($method === 'GET' && $route === 'health') {
Response::json(['status' => 'ok', 'serverTime' => gmdate('c')]);
}
Response::error('not_found', "Route not found: {$route}", 404);
} catch (\Throwable $e) {
error_log('[PSLauncher] ' . $e->getMessage() . "\n" . $e->getTraceAsString());
Response::error('server_error', 'Internal server error', 500);
} }
if ($method === 'GET' && $route === 'health') {
Response::json([
'status' => 'ok',
'serverTime' => gmdate('c'),
'phpVersion' => PHP_VERSION,
'configLoaded' => is_array($config),
]);
}
if ($method === 'GET' && $route === 'debug') {
Response::json([
'status' => 'ok',
'route' => $route,
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
'script_name' => $_SERVER['SCRIPT_NAME'] ?? '',
'php' => PHP_VERSION,
'sodium' => function_exists('sodium_crypto_sign_detached'),
'pdo_mysql' => extension_loaded('pdo_mysql'),
'manifest_file' => is_file(dirname(__DIR__) . '/manifest/versions.json'),
]);
}
Response::error('not_found', "Route not found: '{$route}'", 404);