Admin backoffice — UI redesign : - Pages versions.php + licenses.php : remplace les <details> inline qui débordaient horizontalement par UN bouton « ✎ Modifier » par row qui ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes, BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel, BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets. - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la clé aux clients. - Overlay « hashing en cours » plein écran sur tous les boutons de hash (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé par scope (bulk vs single). - Date de release passée de datetime-local à date (l'heure n'a pas de sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an pour min_license_date (= license standard couvre les releases sur 1 an). Emails de notification release : - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV) - Onglet « Contacts » sur la modal licenses pour saisir les emails par license (parsing tolérant : CSV, ligne par ligne, point-virgule) - Bouton « ✉ Notifier » par version : POST notify_release filtre les licenses éligibles (channel match + min_license_date + can_see_betas pour BÊTA) et envoie un email HTML à chaque contact (dédup global) - Template email table-based + bgcolor (compat Outlook/Word engine), navy foncé #0F172A, logo Asterion en CID embed (= affichage direct sans demande de permission Outlook), bouton download installer centré (align="center" + margin auto), release notes en <pre> - Mailer.php helper : parse emails, multipart/related avec attachments inline, fallback execCommand pour clipboard 4-digit version support (X.Y.Z.B) : - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent = 0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve le format d'origine au round-trip. - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php, Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit - Use case : dev/test iterations cohabitant avec leur release stable Bugs fixes : - migrate.php : strip ligne par ligne les commentaires SQL avant le check is-empty. Sans ça, le PREMIER chunk d'un fichier migration (= header + premier ALTER) commençait par `--` et était silencieusement skip → ALTER jamais appliqué. Affectait migrations 003, 004. - SignManifest::getOrComputeSha256 ignorait son cache interne quand force demandé par le caller, retournant l'ancien hash en 0 ms même après re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force. Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique. - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh triggers, avec HEAD probe pour comparer taille serveur vs manifest → message d'erreur explicite si ZIP tronqué. Bps display lissé sur une fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un segment finit / Polly retry. SHA mismatch popup enrichi avec les deux SHAs (attendu vs calculé) extraits via regex de l'exception. - DownloadUrl.php : signature de l'URL utilisait un template hardcodé /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit maintenant download.url du manifest et signe le filename réel. Strings i18n (5 langues) : - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch, stale manifest, manifest refreshed auto-retry, force fresh menu Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
7.9 KiB
PHP
177 lines
7.9 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
|
|
{
|
|
// 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 <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),
|
|
]);
|
|
}
|
|
|
|
// 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'] ?? '',
|
|
]);
|
|
}
|
|
}
|