* * 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 { // Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test). if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) { Response::error('invalid_version', 'Version invalide', 400); } // Auth via Authorization: Bearer // 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), ]); } // Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité). // Avant : on utilisait un template hardcodé `proserve-{version}.zip`, // ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename // en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence : // l'endpoint retournait une URL signée pointant vers un fichier inexistant // → 404 systématique côté client sans aucune indication serveur. // Maintenant : on lit `download.url` du manifest, on extrait le filename // via parse_url + basename, on vérifie qu'il existe physiquement dans // /builds/, et seulement après on signe. $manifestUrl = $entry['download']['url'] ?? ''; if ($manifestUrl === '') { Response::error('manifest_incomplete', "L'entrée manifest pour v{$version} est sans download.url", 500); } $parsedUrl = parse_url($manifestUrl); $urlPath = $parsedUrl['path'] ?? ''; $filename = basename($urlPath); // Whitelist défensive sur le filename : caractères safe + suffixe .zip. // Évite path traversal et autres injections via un manifest corrompu. if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) { Response::error('manifest_invalid_filename', "Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500); } // Vérifie que le fichier existe physiquement avant de signer une URL morte. // Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans // /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au // lieu d'envoyer le client en 404 silencieux, on retourne une erreur // serveur claire qui apparaît dans les logs PHP + client. $buildsDir = dirname(__DIR__, 2) . '/builds'; $physicalPath = $buildsDir . '/' . $filename; if (!is_file($physicalPath)) { Response::error('file_missing', "Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.", 500, [ 'manifestUrl' => $manifestUrl, 'expectedPath' => $physicalPath, ]); } // 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/' . $filename; $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'] ?? '', ]); } }