Files
PS_Launcher/server/api/routes/DownloadUrl.php
j.foucher 962f5a8ce0 Server: faster releases (cache hashes), per-version skip, longer signed-URL TTL
- gate.php : ETag now derived from size+mtime instead of md5_file($zip).
  Previously the gate hashed the entire 14 GB ZIP on every HEAD/GET call
  (including each of 8 parallel segments) → 30s-5min preparation lag for
  clients before the first byte. Now <1ms per request.
- SignManifest : disk-backed cache for SHA-256 keyed by (path,size,mtime).
  Re-signing 5×14 GB versions used to take ~25 min, now ~1s when nothing
  changed. New "Force re-hash" toggle in admin to ignore the cache.
- versions.php : per-row "🔁 Hash" button to sign a single version, plus
  a "⚙ Hash" dropdown to toggle hashAlgorithm:none for builds where the
  user accepts skipping client-side verification (manifest stays signed
  Ed25519, only the per-ZIP SHA-256 verification is bypassed).
- DownloadUrl.php : signed-URL TTL bumped 1h → 6h to cover slow ADSL users
  who need >1h to finish a 14 GB download.
- .gitignore : track server/builds/.htaccess + gate.php (still ignore the
  actual ZIP/exe binaries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:32 +02:00

136 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Crypto;
use PSLauncher\Db;
use PSLauncher\Response;
/**
* GET /api/download-url/{version}
* Header : Authorization: Bearer <licenseKey>
*
* Vérifie la license, vérifie qu'elle autorise cette version (date d'entitlement
* >= minLicenseDate), puis renvoie une URL HMAC-signée valide 1 h vers le ZIP
* dans /builds/.
*/
final class DownloadUrl
{
public static function handle(array $config, string $version): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
Response::error('invalid_version', 'Version invalide', 400);
}
// Auth via Authorization: Bearer <licenseKey>
// Apache OVH (et autres hébergements FastCGI) strippe parfois le header. On
// tente plusieurs sources :
// - $_SERVER['HTTP_AUTHORIZATION'] (cas standard)
// - $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (mod_rewrite passthrough)
// - apache_request_headers() (présent quand mod_php)
// - getallheaders() (idem)
// - ?key=... (fallback explicite)
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if ($authHeader === '' && function_exists('apache_request_headers')) {
$h = apache_request_headers();
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
}
if ($authHeader === '' && function_exists('getallheaders')) {
$h = getallheaders();
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
}
$licenseKey = '';
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
$licenseKey = trim($m[1]);
} else {
$licenseKey = trim((string)($_GET['key'] ?? ''));
}
if ($licenseKey === '') {
Response::error('unauthorized',
'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
}
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, download_entitlement_until, revoked_at
FROM licenses WHERE license_key = ? LIMIT 1'
);
$stmt->execute([$licenseKey]);
$lic = $stmt->fetch();
if (!$lic) {
Response::error('invalid', 'License inconnue', 401);
}
if ($lic['revoked_at'] !== null) {
Response::error('revoked', 'License révoquée', 403);
}
// Charge le manifest pour récupérer minLicenseDate de cette version
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
}
$manifest = json_decode(file_get_contents($manifestPath), true);
$entry = null;
foreach ($manifest['versions'] ?? [] as $v) {
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
}
if (!$entry) {
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
}
// Vérif droits téléchargement
$entUntil = strtotime($lic['download_entitlement_until']);
$minDate = isset($entry['minLicenseDate']) ? strtotime($entry['minLicenseDate']) : 0;
if ($minDate > 0 && $entUntil < $minDate) {
Response::error('entitlement_expired',
'Cette license n\'autorise pas cette version (date d\'expiration trop ancienne)',
403,
[
'entitlementUntil' => date(\DateTimeInterface::ATOM, $entUntil),
'minLicenseDate' => date(\DateTimeInterface::ATOM, $minDate),
]);
}
// Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit)
// - utilisabilité (un user en ADSL 8 Mbps mettra ~4 h pour DL 14 Go)
// Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip';
$exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {
Response::error('config_error', 'hmac_secret non configuré', 500);
}
$sigInput = $relPath . '|' . $exp . '|' . $lic['id'];
$sig = Crypto::hmacHex($sigInput, $secret);
$signedUrl = $baseUrl . $relPath . '?exp=' . $exp . '&lic=' . $lic['id'] . '&sig=' . $sig;
// Audit
try {
$db->prepare(
'INSERT INTO audit_log (ts, license_id, ip, event, detail) VALUES (NOW(), ?, ?, ?, ?)'
)->execute([
$lic['id'],
$_SERVER['REMOTE_ADDR'] ?? null,
'download_url_issued',
json_encode(['version' => $version], JSON_UNESCAPED_UNICODE),
]);
} catch (\Throwable) { /* best-effort */ }
Response::json([
'url' => $signedUrl,
'expiresAt' => date(\DateTimeInterface::ATOM, $exp),
'sizeBytes' => $entry['download']['sizeBytes'] ?? 0,
'sha256' => $entry['download']['sha256'] ?? '',
]);
}
}