Files
PS_Launcher/server/admin/licenses.php
j.foucher 9de8e95910 v0.24.5 — Install Program Files, multi-PC DL reliability, polish UI
Installeur :
- Bascule en Program Files (autopf) avec PrivilegesRequired=admin.
  L'auto-update reste fonctionnel : LauncherSelfUpdater détecte le dossier
  protégé et lance PS_Launcher.Updater.exe via Verb=runas (UAC à chaque MAJ).
- build-installer.ps1 : détection ISCC.exe robuste (Program Files + LocalAppData
  pour install winget user-mode + PATH), messages d'erreur explicites avec URL
  et commande winget, suppression accents (compat PS 5.1 sans BOM).

Téléchargements multi-PC :
- DownloadManager : détection des connexions fermées prématurément (PHP-FPM
  request_terminate_timeout). Sans ça, segment marqué Completed=false sans
  exception → fichier sparse final avec trous → SHA-256 KO. Lance maintenant
  HttpResumableException(transient) pour que Polly retry au bon offset.
- ParallelDownloadSegments : 16 → 6 par défaut pour permettre plusieurs PCs
  simultanés sans saturer le pool PHP-FPM OVH (~30 workers).

UI :
- Fenêtre maximisée par défaut au démarrage (WindowState=Maximized).
- Fix maximize qui cachait le footer / barre de DL (WM_GETMINMAXINFO clamp
  sur work area, remplace le margin hack 7px imprécis).
- Sidebar : bloc info en bas avec PS_Launcher vX.Y.Z + IPv4 locale alignés.
- Copyright remonté plus près du footer (margin 28 → 8).
- Settings → Health checks : boutons ▲/▼ pour réordonner, CanExecute auto.
- HealthCheck refresh défaut : 5000 → 2000 ms.

Bug fix UI :
- MainViewModel.RebuildList preserve l'état du row actif pendant un DL.
  Sinon ouvrir Settings/License pendant un DL recréait les rows from scratch
  → UI affichait "Reprendre" + "Annuler" alors que le DL tournait. Les
  progress callbacks pointent maintenant sur _activeRow (résolution dynamique)
  au lieu de capturer le row local.

Defaults config :
- ServerBaseUrl : example.com → asterionvr.com (out-of-the-box).
- Vive Business Streaming check : HtcConnectionUtility → rrserver.

Backoffice (server/admin/licenses.php) :
- Action set_max_machines : bouton "Slots" pour ajuster max_machines à chaud
  sur une licence existante. Refus de descendre sous le nombre de machines
  déjà actives.

Build :
- AllowUnsafeBlocks=true sur PSLauncher.App.csproj (compat WinRT generator
  récent qui émet du code unsafe dans WinRTGenericInstantiation.g.cs).

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

348 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Db;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$db = Db::get($config);
$message = null; $messageType = 'success';
$newKey = null;
function generateLicenseKey(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$groups = [];
for ($g = 0; $g < 4; $g++) {
$s = '';
for ($i = 0; $i < 4; $i++) $s .= $alphabet[random_int(0, strlen($alphabet) - 1)];
$groups[] = $s;
}
return 'PRSRV-' . implode('-', $groups);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
try {
if ($action === 'create') {
$owner = trim($_POST['owner'] ?? '');
$until = trim($_POST['until'] ?? '');
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
$notes = trim($_POST['notes'] ?? '') ?: null;
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
}
for ($attempts = 0; $attempts < 5; $attempts++) {
$newKey = generateLicenseKey();
try {
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)')
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]);
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
break;
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'Duplicate')) { continue; }
throw $e;
}
}
}
elseif ($action === 'revoke') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('UPDATE licenses SET revoked_at = NOW() WHERE id = ?')->execute([$id]);
$message = "License #{$id} révoquée.";
}
elseif ($action === 'unrevoke') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('UPDATE licenses SET revoked_at = NULL WHERE id = ?')->execute([$id]);
$message = "License #{$id} réactivée.";
}
elseif ($action === 'extend') {
$id = (int)($_POST['id'] ?? 0);
$newUntil = trim($_POST['new_until'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newUntil)) {
throw new Exception('Date invalide.');
}
$db->prepare('UPDATE licenses SET download_entitlement_until = ? WHERE id = ?')
->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
}
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
throw new Exception('Nombre de machines invalide (1100).');
}
// Refus de descendre sous le nombre de machines déjà activées —
// sinon état incohérent (machines_count > max_machines). L'admin doit
// d'abord libérer des slots via "Libérer" individuel ou "Libérer toutes".
$stmt = $db->prepare('SELECT COUNT(*) FROM license_machines WHERE license_id = ?');
$stmt->execute([$id]);
$current = (int)$stmt->fetchColumn();
if ($newMax < $current) {
throw new Exception("Impossible : {$current} machines sont déjà actives. Libère des slots d'abord ou choisis ≥ {$current}.");
}
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
$message = "Toutes les machines libérées pour la license #{$id}.";
}
elseif ($action === 'remove_machine') {
$licenseId = (int)($_POST['license_id'] ?? 0);
$machineId = trim((string)($_POST['machine_id'] ?? ''));
if ($licenseId <= 0 || $machineId === '') {
throw new Exception('license_id et machine_id requis');
}
$stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?');
$stmt->execute([$licenseId, $machineId]);
$count = $stmt->rowCount();
$shortId = substr($machineId, 0, 12);
$message = $count > 0
? "Machine {$shortId}… libérée pour la license #{$licenseId}."
: "Aucune machine correspondante trouvée pour license #{$licenseId}.";
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$licenses = $db->query(
'SELECT l.*,
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
FROM licenses l
ORDER BY l.id DESC'
)->fetchAll();
// Pré-fetch toutes les machines de toutes les licenses en une seule requête,
// indexées par license_id pour l'affichage par-row sans N+1.
$allMachines = $db->query(
'SELECT license_id, machine_id, machine_label, first_seen, last_seen
FROM license_machines
ORDER BY last_seen DESC'
)->fetchAll();
$machinesByLicense = [];
foreach ($allMachines as $m) {
$machinesByLicense[(int)$m['license_id']][] = $m;
}
Layout::header('Licenses', 'licenses');
?>
<h1>Licenses</h1>
<?php Layout::flash($message, $messageType); ?>
<?php if ($newKey): ?>
<div class="card" style="border-color: var(--installed); background: var(--installed-bg);">
<h2 style="color: #6EE7B7; margin-top:0">⚡ Nouvelle clé — copie-la dès maintenant</h2>
<pre style="font-size: 18px; font-weight: bold; color: white; background: rgba(0,0,0,0.3); margin: 0;"><?= htmlspecialchars($newKey) ?></pre>
<p class="muted" style="margin-bottom: 0; margin-top: 12px;">Elle est stockée hashée — la prochaine fois que tu rechargeras cette page elle ne sera plus visible.</p>
</div>
<?php endif; ?>
<div class="card">
<h2>Émettre une license</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="create">
<div class="row">
<div class="col field">
<label>Nom du client</label>
<input type="text" name="owner" required placeholder="ACME Corp">
</div>
<div class="col field">
<label>Date d'expiration des téléchargements</label>
<input type="date" name="until" required>
</div>
<div class="col field" style="flex: 0 0 140px">
<label>Max machines</label>
<input type="number" name="max_machines" value="1" min="1" max="100">
</div>
</div>
<div class="field">
<label>Notes internes (optionnel)</label>
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
</div>
<button class="btn btn-success" type="submit">Émettre la license</button>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Émise</th>
<th>Expire</th>
<th>Machines</th>
<th>Statut</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $l):
$isExpired = strtotime($l['download_entitlement_until']) < time();
$isRevoked = $l['revoked_at'] !== null;
if ($isRevoked) { $status = 'révoquée'; $cls = 'badge-danger'; }
elseif ($isExpired) { $status = 'expirée'; $cls = 'badge-warning'; }
else { $status = 'active'; $cls = 'badge-success'; }
?>
<tr>
<td>#<?= $l['id'] ?></td>
<td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
<?php if (!empty($l['notes'])): ?>
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?>
</td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
<td>
<?php if ($l['machines_count'] > 0): ?>
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
style="color: var(--text); text-decoration: none; cursor: pointer;"
title="Cliquer pour voir les machines">
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
</a>
<?php else: ?>
0 / <?= $l['max_machines'] ?>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Prolonger</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="date" name="new_until" required style="width: 150px;">
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Slots</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_max_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required
style="width: 70px;"
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
</form>
<?php endif; ?>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">Réactiver</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php
// Sous-row dépliable : liste des machines avec remove individuel.
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
$machines = $machinesByLicense[(int)$l['id']] ?? [];
if (!empty($machines)):
?>
<tr>
<td colspan="7" style="padding: 0;">
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
<summary style="display: none;"></summary>
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
</div>
<table style="width: 100%; font-size: 12px; margin: 0;">
<thead>
<tr>
<th style="width: 280px;">Machine ID (SHA-256)</th>
<th>Label</th>
<th style="width: 130px;">1ère activation</th>
<th style="width: 130px;">Dernière vue</th>
<th style="width: 110px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($machines as $m):
$mid = $m['machine_id'];
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
?>
<tr>
<td>
<code title="<?= htmlspecialchars($mid) ?>"
style="font-family: 'Cascadia Code', Consolas, monospace;">
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
</code>
</td>
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
<td class="muted">
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
<?php if ($isStale): ?>
<span class="badge badge-warning" title="Pas vu depuis &gt; 30 jours, candidat à libération">stale</span>
<?php endif; ?>
</td>
<td style="text-align:right;">
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_machine">
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
<button class="btn btn-danger" type="submit"
style="font-size: 11px; padding: 4px 10px;">
Libérer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php if (empty($licenses)): ?>
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php Layout::footer();