v0.26.0 — Channels per-license + tag BÊTA sur versions
DEUX features liées :
1. CHANNELS : chaque license peut être attribuée à un manifest distinct
(« channel »). Permet de servir des versions différentes selon le
client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
avec fallback transparent sur versions.json. NULL = default.
2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
Visible uniquement par les licenses avec can_see_betas=1. Affichage
d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
les testeurs. Les installations locales déjà présentes restent
visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
le disque du client).
DB :
002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.
Serveur PHP :
- ValidateLicense.php signe channel + canSeeBetas dans la réponse
(ordre des clés CRITIQUE pour matcher le canonical client).
- Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
silencieux sur versions.json si channel inconnu (évite leak de la
liste de channels par probing).
- SignManifest.php prend un channel optionnel → l'admin peut signer
chaque manifest indépendamment.
- admin/licenses.php : dropdown channel + checkbox bêta sur create,
bouton détails repliable par-row pour edit.
- admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
row, dialog repliable « Bêta » avec checkbox + notes des testeurs.
Client C# :
- License.Channel + License.CanSeeBetas (dans le canonical signé).
- VersionManifest.IsBeta + BetaNotes.
- ManifestService prend un channelProvider via DI, lu depuis license
cachée à chaque fetch (lazy, pas de circular dep).
- MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
(mais conserve les installées locales — on ne retire pas l'accès
rétroactivement à ce qui est déjà sur disque).
- VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
- MainWindow.xaml : pill orange à côté du n° version pour le featured
et les rows compactes, tooltip dynamique avec les notes testeurs.
Backward compat signature :
Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
toujours validées via un fallback canonical legacy dans VerifySignature.
Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
hors-ligne et bloquerait les users en mobilité.
Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,26 @@ function generateLicenseKey(): string
|
||||
return 'PRSRV-' . implode('-', $groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les channels disponibles en scannant manifest/versions-*.json.
|
||||
* Le channel "default" est toujours présent (= manifest/versions.json).
|
||||
* Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest.
|
||||
*/
|
||||
function listAvailableChannels(): array
|
||||
{
|
||||
$dir = dirname(__DIR__) . '/manifest';
|
||||
$channels = ['' => '(default)']; // clé vide = NULL en DB
|
||||
if (is_dir($dir)) {
|
||||
foreach (glob($dir . '/versions-*.json') as $file) {
|
||||
$name = basename($file);
|
||||
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
|
||||
$channels[$m[1]] = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $channels;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
Auth::checkCsrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
@@ -37,6 +57,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$until = trim($_POST['until'] ?? '');
|
||||
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
|
||||
$notes = trim($_POST['notes'] ?? '') ?: null;
|
||||
// Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
|
||||
$channel = trim((string)($_POST['channel'] ?? ''));
|
||||
if ($channel === '') {
|
||||
$channel = null;
|
||||
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
|
||||
throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
|
||||
}
|
||||
$canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
|
||||
|
||||
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).');
|
||||
@@ -45,8 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
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]);
|
||||
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
|
||||
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
|
||||
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
|
||||
break;
|
||||
} catch (PDOException $e) {
|
||||
@@ -75,6 +103,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
->execute([$newUntil . ' 23:59:59', $id]);
|
||||
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
|
||||
}
|
||||
elseif ($action === 'set_channel') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$channel = trim((string)($_POST['channel'] ?? ''));
|
||||
if ($channel === '') {
|
||||
$channel = null; // default manifest
|
||||
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
|
||||
throw new Exception('Channel invalide.');
|
||||
}
|
||||
$db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
|
||||
$label = $channel ?? 'default';
|
||||
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
||||
}
|
||||
elseif ($action === 'toggle_betas') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
|
||||
$row->execute([$id]);
|
||||
$now = (int)$row->fetchColumn();
|
||||
$message = $now
|
||||
? "License #{$id} : accès aux versions BÊTA activé."
|
||||
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
||||
}
|
||||
elseif ($action === 'set_max_machines') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$newMax = (int)($_POST['max_machines'] ?? 0);
|
||||
@@ -118,6 +169,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
|
||||
$availableChannels = listAvailableChannels();
|
||||
|
||||
$licenses = $db->query(
|
||||
'SELECT l.*,
|
||||
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
|
||||
@@ -170,6 +223,23 @@ Layout::header('Licenses', 'licenses');
|
||||
<input type="number" name="max_machines" value="1" min="1" max="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
|
||||
<select name="channel">
|
||||
<?php foreach ($availableChannels as $value => $label): ?>
|
||||
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
|
||||
Accès aux versions BÊTA
|
||||
</label>
|
||||
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes internes (optionnel)</label>
|
||||
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
||||
@@ -187,6 +257,7 @@ Layout::header('Licenses', 'licenses');
|
||||
<th>Émise</th>
|
||||
<th>Expire</th>
|
||||
<th>Machines</th>
|
||||
<th>Channel / Bêta</th>
|
||||
<th>Statut</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
@@ -220,6 +291,41 @@ Layout::header('Licenses', 'licenses');
|
||||
0 / <?= $l['max_machines'] ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<details style="display: inline-block;">
|
||||
<summary style="cursor: pointer; list-style: none;">
|
||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;">
|
||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||
</code>
|
||||
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β</span>
|
||||
<?php endif; ?>
|
||||
</summary>
|
||||
<div style="margin-top: 8px; min-width: 220px;">
|
||||
<form method="post" style="display: flex; gap: 4px; align-items: center; margin-bottom: 6px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channel">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<select name="channel" style="flex: 1;">
|
||||
<?php foreach ($availableChannels as $value => $label):
|
||||
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">OK</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="toggle_betas">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit" style="width: 100%;">
|
||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'Retirer accès BÊTA' : 'Activer accès BÊTA' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</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;">
|
||||
@@ -278,7 +384,7 @@ Layout::header('Licenses', 'licenses');
|
||||
if (!empty($machines)):
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="7" style="padding: 0;">
|
||||
<td colspan="8" 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);">
|
||||
|
||||
Reference in New Issue
Block a user