Compare commits
13 Commits
3d691c3e38
...
aab2e41152
| Author | SHA1 | Date | |
|---|---|---|---|
| aab2e41152 | |||
| d9c3f09e9a | |||
| 67678fe173 | |||
| 38194c6f0c | |||
| 72f99e0c56 | |||
| 49a3b855af | |||
| 0c69bb1c85 | |||
| 7638a7a25c | |||
| 7ff5e56102 | |||
| 95c70903e7 | |||
| 2b95472393 | |||
| 9499c6ae58 | |||
| c371a79f93 |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.25.10"
|
#define MyAppVersion "0.27.2"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
|
|||||||
231
server/admin/channels.php
Normal file
231
server/admin/channels.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/lib/Auth.php';
|
||||||
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
|
||||||
|
use PSLauncher\Admin\Auth;
|
||||||
|
use PSLauncher\Admin\Layout;
|
||||||
|
use PSLauncher\Admin\Channels;
|
||||||
|
|
||||||
|
Auth::requireLogin();
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
|
||||||
|
$manifestDir = dirname(__DIR__) . '/manifest';
|
||||||
|
$message = null; $messageType = 'success';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
Auth::checkCsrf();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
try {
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
|
||||||
|
if ($action === 'create') {
|
||||||
|
$rawName = (string)($_POST['name'] ?? '');
|
||||||
|
$name = Channels::normalizeName($rawName);
|
||||||
|
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
|
||||||
|
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
|
||||||
|
|
||||||
|
if (!Channels::isValidName($name)) {
|
||||||
|
throw new \RuntimeException("Nom invalide après normalisation : « {$rawName} » → « {$name} ». Saisis quelque chose comme « asterion-vr » ou « police ».");
|
||||||
|
}
|
||||||
|
if ($name === 'default') {
|
||||||
|
throw new \RuntimeException("Le channel « default » est implicite, on ne peut pas en créer un autre.");
|
||||||
|
}
|
||||||
|
foreach ($registry['channels'] as $c) {
|
||||||
|
if (($c['name'] ?? '') === $name) {
|
||||||
|
throw new \RuntimeException("Channel « {$name} » existe déjà.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = ['name' => $name, 'label' => $label];
|
||||||
|
if ($desc !== null) $entry['description'] = $desc;
|
||||||
|
$registry['channels'][] = $entry;
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » créé.";
|
||||||
|
if ($name !== $rawName) {
|
||||||
|
$message .= " (Nom normalisé depuis « {$rawName} ».)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elseif ($action === 'edit') {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
|
||||||
|
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
|
||||||
|
$found = false;
|
||||||
|
foreach ($registry['channels'] as &$c) {
|
||||||
|
if (($c['name'] ?? '') === $name) {
|
||||||
|
$c['label'] = $label;
|
||||||
|
if ($desc === null) unset($c['description']);
|
||||||
|
else $c['description'] = $desc;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($c);
|
||||||
|
if (!$found) throw new \RuntimeException("Channel « {$name} » introuvable.");
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » mis à jour.";
|
||||||
|
}
|
||||||
|
|
||||||
|
elseif ($action === 'delete') {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
if ($name === 'default') throw new \RuntimeException("Le channel « default » ne peut pas être supprimé.");
|
||||||
|
|
||||||
|
// Sécurité : empêche la suppression si des versions ou licenses
|
||||||
|
// référencent encore ce channel — sinon ces refs deviennent silencieusement
|
||||||
|
// pendantes et l'admin oublie pourquoi tel client ne voit rien.
|
||||||
|
$usage = checkChannelUsage($manifestDir, $name);
|
||||||
|
if ($usage['versions'] > 0 || $usage['licenses'] > 0) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Impossible de supprimer « {$name} » : "
|
||||||
|
. "{$usage['versions']} version(s) taggée(s) et {$usage['licenses']} license(s) attribuée(s) à ce channel. "
|
||||||
|
. "Détague d'abord les versions (page Versions) et change les licenses concernées (page Licenses)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry['channels'] = array_values(array_filter(
|
||||||
|
$registry['channels'],
|
||||||
|
fn($c) => ($c['name'] ?? '') !== $name
|
||||||
|
));
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » supprimé.";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
$messageType = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les versions et licenses qui pointent encore sur un channel.
|
||||||
|
* Sert de garde-fou avant suppression.
|
||||||
|
*/
|
||||||
|
function checkChannelUsage(string $manifestDir, string $channelName): array
|
||||||
|
{
|
||||||
|
$usage = ['versions' => 0, 'licenses' => 0];
|
||||||
|
|
||||||
|
$manifestPath = $manifestDir . '/versions.json';
|
||||||
|
if (is_file($manifestPath)) {
|
||||||
|
$m = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
if (is_array($m) && isset($m['versions'])) {
|
||||||
|
foreach ($m['versions'] as $v) {
|
||||||
|
$channels = $v['channels'] ?? [];
|
||||||
|
if (in_array($channelName, $channels, true)) $usage['versions']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
$db = \PSLauncher\Db::get($config);
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) FROM licenses WHERE channel = ?');
|
||||||
|
$stmt->execute([$channelName]);
|
||||||
|
$usage['licenses'] = (int)$stmt->fetchColumn();
|
||||||
|
} catch (\Throwable) { /* DB not reachable from this context, best effort */ }
|
||||||
|
|
||||||
|
return $usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
|
||||||
|
Layout::header('Channels', 'channels');
|
||||||
|
?>
|
||||||
|
<h1>Channels de distribution</h1>
|
||||||
|
|
||||||
|
<?php Layout::flash($message, $messageType); ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Comment ça marche</h2>
|
||||||
|
<ul class="muted">
|
||||||
|
<li><strong>default</strong> = channel public implicite. Toutes les versions taggées « default » (ou sans tag) sont visibles par tous les clients, peu importe leur license.</li>
|
||||||
|
<li>Les autres channels (<strong>asterion-vr</strong>, <strong>police</strong>, …) sont des canaux privés. Une version taggée uniquement sur un channel privé n'est vue QUE par les licenses attribuées à ce channel.</li>
|
||||||
|
<li>Une version peut être taggée sur plusieurs channels en même temps (multi-channel). Une license n'est attribuée qu'à un seul channel à la fois.</li>
|
||||||
|
<li>Sémantique additive : un client sur le channel « police » voit les versions « default » + les versions « police ». Pour rendre une version invisible au public, ne la tague PAS « default ».</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Créer un channel</h2>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col field">
|
||||||
|
<label>Nom (identifiant ASCII)</label>
|
||||||
|
<input type="text" name="name" required maxlength="80" placeholder="asterion-vr"
|
||||||
|
title="Sera normalisé en lowercase + tirets côté serveur (« ASTERION VR » → « asterion-vr »).">
|
||||||
|
</div>
|
||||||
|
<div class="col field">
|
||||||
|
<label>Label (affiché dans les UI)</label>
|
||||||
|
<input type="text" name="label" maxlength="120" placeholder="ASTERION VR (interne)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description (optionnel)</label>
|
||||||
|
<input type="text" name="description" maxlength="255" placeholder="Builds internes pour l'équipe ASTERION">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success" type="submit">Créer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Channels existants</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($registry['channels'] as $c):
|
||||||
|
$isDefault = ($c['name'] ?? '') === 'default';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code><?= htmlspecialchars($c['name'] ?? '') ?></code>
|
||||||
|
<?php if ($isDefault): ?>
|
||||||
|
<span class="badge badge-success" style="margin-left: 6px;" title="Channel implicite, immutable">implicite</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($c['label'] ?? $c['name'] ?? '') ?></td>
|
||||||
|
<td class="muted"><?= htmlspecialchars($c['description'] ?? '') ?></td>
|
||||||
|
<td style="text-align:right; white-space: nowrap;">
|
||||||
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
|
<summary class="btn btn-secondary">Éditer</summary>
|
||||||
|
<form method="post" style="margin-top: 8px; min-width: 320px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="edit">
|
||||||
|
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Label</label>
|
||||||
|
<input type="text" name="label" value="<?= htmlspecialchars($c['label'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" name="description" value="<?= htmlspecialchars($c['description'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php if (!$isDefault): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer le channel <?= htmlspecialchars($c['name']) ?> ?\nLes versions et licenses qui le référencent doivent être détaguées d\'abord.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
|
||||||
|
<button class="btn btn-danger" type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php Layout::footer();
|
||||||
96
server/admin/lib/Channels.php
Normal file
96
server/admin/lib/Channels.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PSLauncher\Admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registre des channels de distribution. Stocké dans manifest/channels.json
|
||||||
|
* pour rester portable / pas dépendre de la DB pour cette config simple.
|
||||||
|
*
|
||||||
|
* Une entrée a 3 champs :
|
||||||
|
* - name : identifiant ASCII utilisé en code partout (whitelist a-z0-9_-)
|
||||||
|
* - label : libellé human-friendly affiché dans les UI admin
|
||||||
|
* - description : contexte libre pour l'admin (optionnel)
|
||||||
|
*
|
||||||
|
* Le channel "default" est implicite et toujours présent : il représente les
|
||||||
|
* versions publiques (vues par toutes les licenses, peu importe leur tag). Les
|
||||||
|
* versions sans tag explicite sont implicitement dans "default".
|
||||||
|
*
|
||||||
|
* Les licenses pointent vers UN channel — elles voient les versions taggées
|
||||||
|
* avec ce channel ET celles taggées "default" (sémantique additive).
|
||||||
|
*/
|
||||||
|
final class Channels
|
||||||
|
{
|
||||||
|
/** @return array{channels: array<int, array{name:string,label:string,description?:string}>} */
|
||||||
|
public static function load(string $manifestDir): array
|
||||||
|
{
|
||||||
|
$path = $manifestDir . '/channels.json';
|
||||||
|
$base = ['channels' => []];
|
||||||
|
if (is_file($path)) {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
$json = json_decode((string)$raw, true);
|
||||||
|
if (is_array($json) && isset($json['channels']) && is_array($json['channels'])) {
|
||||||
|
$base['channels'] = $json['channels'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantit que "default" est toujours présent en tête (immutable depuis la UI).
|
||||||
|
$hasDefault = false;
|
||||||
|
foreach ($base['channels'] as $c) {
|
||||||
|
if (($c['name'] ?? '') === 'default') { $hasDefault = true; break; }
|
||||||
|
}
|
||||||
|
if (!$hasDefault) {
|
||||||
|
array_unshift($base['channels'], [
|
||||||
|
'name' => 'default',
|
||||||
|
'label' => 'Public',
|
||||||
|
'description' => 'Versions publiques visibles par tous les clients',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save(string $manifestDir, array $registry): void
|
||||||
|
{
|
||||||
|
$path = $manifestDir . '/channels.json';
|
||||||
|
if (!is_dir($manifestDir)) mkdir($manifestDir, 0755, true);
|
||||||
|
file_put_contents(
|
||||||
|
$path,
|
||||||
|
json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne true si <code>$name</code> est un identifiant de channel valide :
|
||||||
|
* lowercase, chiffres, tirets et underscores, 1 à 64 caractères.
|
||||||
|
*/
|
||||||
|
public static function isValidName(string $name): bool
|
||||||
|
{
|
||||||
|
return (bool)preg_match('/^[a-z0-9_-]{1,64}$/', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un nom saisi par l'admin : lowercase, espaces → tirets, retire
|
||||||
|
* les chars hors whitelist, compresse les tirets multiples, trim.
|
||||||
|
* Identique à ps_normalize_channel() dans versions.php — centralisé ici
|
||||||
|
* pour les futurs ajouts.
|
||||||
|
*/
|
||||||
|
public static function normalizeName(string $raw): string
|
||||||
|
{
|
||||||
|
$s = strtolower(trim($raw));
|
||||||
|
$s = (string)preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
|
||||||
|
$s = (string)preg_replace('/[^a-z0-9_-]/', '', $s);
|
||||||
|
$s = (string)preg_replace('/-+/', '-', $s);
|
||||||
|
$s = trim($s, '-_');
|
||||||
|
return substr($s, 0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Liste des noms de channel pour les dropdowns.
|
||||||
|
*/
|
||||||
|
public static function listNames(string $manifestDir): array
|
||||||
|
{
|
||||||
|
$reg = self::load($manifestDir);
|
||||||
|
return array_map(fn($c) => (string)($c['name'] ?? ''), $reg['channels']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ HTML;
|
|||||||
'dashboard' => ['index.php', 'Dashboard'],
|
'dashboard' => ['index.php', 'Dashboard'],
|
||||||
'licenses' => ['licenses.php', 'Licenses'],
|
'licenses' => ['licenses.php', 'Licenses'],
|
||||||
'versions' => ['versions.php', 'Versions'],
|
'versions' => ['versions.php', 'Versions'],
|
||||||
|
'channels' => ['channels.php', 'Channels'],
|
||||||
'launcher' => ['launcher.php', 'Launcher'],
|
'launcher' => ['launcher.php', 'Launcher'],
|
||||||
'audit' => ['audit.php', 'Audit'],
|
'audit' => ['audit.php', 'Audit'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
require __DIR__ . '/lib/Channels.php';
|
||||||
require __DIR__ . '/../api/lib/Db.php';
|
require __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
|
use PSLauncher\Admin\Channels;
|
||||||
use PSLauncher\Db;
|
use PSLauncher\Db;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
@@ -27,6 +29,25 @@ function generateLicenseKey(): string
|
|||||||
return 'PRSRV-' . implode('-', $groups);
|
return 'PRSRV-' . implode('-', $groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les channels disponibles depuis le registre channels.json. Le channel
|
||||||
|
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
|
||||||
|
* « pas de tag, voit que default »).
|
||||||
|
*/
|
||||||
|
function listAvailableChannels(): array
|
||||||
|
{
|
||||||
|
$manifestDir = dirname(__DIR__) . '/manifest';
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
$out = ['' => '(aucun — voit uniquement « default »)'];
|
||||||
|
foreach ($registry['channels'] as $c) {
|
||||||
|
$name = (string)($c['name'] ?? '');
|
||||||
|
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
|
||||||
|
$label = $c['label'] ?? $name;
|
||||||
|
$out[$name] = "{$name} — {$label}";
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
Auth::checkCsrf();
|
Auth::checkCsrf();
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
@@ -37,6 +58,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$until = trim($_POST['until'] ?? '');
|
$until = trim($_POST['until'] ?? '');
|
||||||
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
|
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
|
||||||
$notes = trim($_POST['notes'] ?? '') ?: null;
|
$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)) {
|
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).');
|
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
|
||||||
@@ -45,8 +74,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||||
$newKey = generateLicenseKey();
|
$newKey = generateLicenseKey();
|
||||||
try {
|
try {
|
||||||
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)')
|
$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, $notes]);
|
->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.";
|
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
|
||||||
break;
|
break;
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
@@ -75,6 +104,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
->execute([$newUntil . ' 23:59:59', $id]);
|
->execute([$newUntil . ' 23:59:59', $id]);
|
||||||
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
|
$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') {
|
elseif ($action === 'set_max_machines') {
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$newMax = (int)($_POST['max_machines'] ?? 0);
|
$newMax = (int)($_POST['max_machines'] ?? 0);
|
||||||
@@ -118,6 +170,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$availableChannels = listAvailableChannels();
|
||||||
|
|
||||||
$licenses = $db->query(
|
$licenses = $db->query(
|
||||||
'SELECT l.*,
|
'SELECT l.*,
|
||||||
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
|
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
|
||||||
@@ -170,6 +224,23 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<input type="number" name="max_machines" value="1" min="1" max="100">
|
<input type="number" name="max_machines" value="1" min="1" max="100">
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label>Notes internes (optionnel)</label>
|
<label>Notes internes (optionnel)</label>
|
||||||
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
||||||
@@ -187,6 +258,7 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<th>Émise</th>
|
<th>Émise</th>
|
||||||
<th>Expire</th>
|
<th>Expire</th>
|
||||||
<th>Machines</th>
|
<th>Machines</th>
|
||||||
|
<th>Channel / Bêta</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th style="text-align:right">Actions</th>
|
<th style="text-align:right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -220,6 +292,52 @@ Layout::header('Licenses', 'licenses');
|
|||||||
0 / <?= $l['max_machines'] ?>
|
0 / <?= $l['max_machines'] ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
|
||||||
|
<div style="margin-bottom: 6px;">
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||||
|
title="Channel actuellement attribué à cette license">
|
||||||
|
<?= 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">β BÊTA</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
|
||||||
|
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
|
||||||
|
pour trouver l'option. -->
|
||||||
|
<form method="post" style="display: inline-block; margin-right: 4px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="toggle_betas">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
|
||||||
|
type="submit" style="font-size: 11px; padding: 4px 10px;"
|
||||||
|
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
|
||||||
|
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Channel : modifier via un menu compact -->
|
||||||
|
<details style="display: inline-block;">
|
||||||
|
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
|
||||||
|
✎ Channel
|
||||||
|
</summary>
|
||||||
|
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channel">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<select name="channel" style="min-width: 160px;">
|
||||||
|
<?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>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||||
<td style="text-align:right; white-space: nowrap;">
|
<td style="text-align:right; white-space: nowrap;">
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
@@ -278,7 +396,7 @@ Layout::header('Licenses', 'licenses');
|
|||||||
if (!empty($machines)):
|
if (!empty($machines)):
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" style="padding: 0;">
|
<td colspan="8" style="padding: 0;">
|
||||||
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
|
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
|
||||||
<summary style="display: none;"></summary>
|
<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="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
|
||||||
|
|||||||
@@ -2,36 +2,122 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
|
use PSLauncher\Admin\Channels;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
$config = require __DIR__ . '/../api/config.php';
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
|
||||||
$root = dirname(__DIR__);
|
$root = dirname(__DIR__);
|
||||||
$manifestPath = "$root/manifest/versions.json";
|
$manifestDir = "$root/manifest";
|
||||||
$buildsDir = "$root/builds";
|
$buildsDir = "$root/builds";
|
||||||
$notesDir = "$root/releasenotes";
|
$notesDir = "$root/releasenotes";
|
||||||
|
$manifestPath = "$manifestDir/versions.json";
|
||||||
|
|
||||||
$message = null; $messageType = 'success';
|
$message = null; $messageType = 'success';
|
||||||
|
|
||||||
|
// Liste des channels déclarés dans channels.json (incluant "default" implicite)
|
||||||
|
$channelRegistry = Channels::load($manifestDir);
|
||||||
|
$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
|
||||||
|
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
|
||||||
|
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
|
||||||
|
*/
|
||||||
|
function sanitizeChannels(array $posted, array $allowedNames): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($posted as $c) {
|
||||||
|
$c = (string)$c;
|
||||||
|
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
|
||||||
|
$out[] = $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($out)) $out = ['default'];
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID stable interne pour identifier une entrée du manifest. Permet d'avoir
|
||||||
|
* plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police +
|
||||||
|
* v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas
|
||||||
|
* un identifiant unique. Le tag de channels distingue qui voit quoi.
|
||||||
|
*
|
||||||
|
* Format : 'v' + 8 hex chars random. Immutable une fois généré.
|
||||||
|
*/
|
||||||
|
function generate_entry_id(): string
|
||||||
|
{
|
||||||
|
try { return 'v' . bin2hex(random_bytes(4)); }
|
||||||
|
catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); }
|
||||||
|
}
|
||||||
|
|
||||||
function loadManifest(string $path): array
|
function loadManifest(string $path): array
|
||||||
{
|
{
|
||||||
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
|
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
|
||||||
return json_decode(file_get_contents($path), true) ?? [];
|
$manifest = json_decode(file_get_contents($path), true) ?? [];
|
||||||
|
|
||||||
|
// Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy
|
||||||
|
// pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change
|
||||||
|
// plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids
|
||||||
|
// jetables et les actions tomberaient à plat au refresh.
|
||||||
|
$dirty = false;
|
||||||
|
foreach ($manifest['versions'] ?? [] as &$v) {
|
||||||
|
if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; }
|
||||||
|
}
|
||||||
|
unset($v);
|
||||||
|
if ($dirty) saveManifest($path, $manifest);
|
||||||
|
|
||||||
|
return $manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveManifest(string $path, array $manifest): void
|
function saveManifest(string $path, array $manifest): void
|
||||||
{
|
{
|
||||||
// Re-tri par SemVer descendant
|
// Re-tri par SemVer descendant. Pour des entrées de même version,
|
||||||
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
|
// tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter
|
||||||
|
// sur un ordre déterministe.
|
||||||
|
usort($manifest['versions'], function($a, $b) {
|
||||||
|
$cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0');
|
||||||
|
return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? '');
|
||||||
|
});
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$path,
|
$path,
|
||||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function find_entry_index_by_id(array $manifest, string $id): ?int
|
||||||
|
{
|
||||||
|
foreach ($manifest['versions'] ?? [] as $i => $v) {
|
||||||
|
if (($v['id'] ?? '') === $id) return $i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un nom de fichier ZIP saisi par l'admin :
|
||||||
|
* - strip tout chemin (path traversal défense, basename)
|
||||||
|
* - whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets, underscores)
|
||||||
|
* - assure le suffixe .zip
|
||||||
|
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable
|
||||||
|
*/
|
||||||
|
function ps_normalize_zip_filename(string $raw, string $version): string
|
||||||
|
{
|
||||||
|
$default = "proserve-{$version}.zip";
|
||||||
|
$name = trim($raw);
|
||||||
|
if ($name === '') return $default;
|
||||||
|
// Strip tout chemin → garde juste le filename (sécurité)
|
||||||
|
$name = basename($name);
|
||||||
|
// Whitelist : on garde un set de chars filename-safe sur Windows + Linux
|
||||||
|
$name = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name);
|
||||||
|
if ($name === '' || $name === '.zip' || $name === '.') return $default;
|
||||||
|
if (!str_ends_with(strtolower($name), '.zip')) $name .= '.zip';
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
Auth::checkCsrf();
|
Auth::checkCsrf();
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
@@ -44,6 +130,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$releasedAt = trim($_POST['released_at'] ?? '');
|
$releasedAt = trim($_POST['released_at'] ?? '');
|
||||||
$notes = $_POST['notes'] ?? '';
|
$notes = $_POST['notes'] ?? '';
|
||||||
$available = isset($_POST['available']);
|
$available = isset($_POST['available']);
|
||||||
|
$isBeta = isset($_POST['is_beta']);
|
||||||
|
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||||
|
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||||
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
||||||
@@ -51,8 +139,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||||||
throw new Exception('Date min_license_date invalide.');
|
throw new Exception('Date min_license_date invalide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le numéro de version peut être DUPLIQUÉ entre channels — c'est
|
||||||
|
// exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour
|
||||||
|
// pompier coexistent). On rejette uniquement la collision sur le
|
||||||
|
// nom de ZIP, parce que là on aurait une vraie ambiguïté physique
|
||||||
|
// : deux entries pointant sur le même fichier sur disque.
|
||||||
|
$zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
||||||
foreach ($manifest['versions'] as $v) {
|
foreach ($manifest['versions'] as $v) {
|
||||||
if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest.");
|
$existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
|
if ($existingZip === $zipFilenameProposed) {
|
||||||
|
throw new Exception(
|
||||||
|
"Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v"
|
||||||
|
. htmlspecialchars($v['version'] ?? '?')
|
||||||
|
. ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$releasedAtIso = $releasedAt !== ''
|
$releasedAtIso = $releasedAt !== ''
|
||||||
@@ -60,74 +162,137 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
: (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
: (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
||||||
|
|
||||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
$manifest['versions'][] = [
|
// Le ZIP a déjà été normalisé pour le check anti-collision plus haut.
|
||||||
|
$zipFilename = $zipFilenameProposed;
|
||||||
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||||||
|
$entryId = generate_entry_id();
|
||||||
|
$entry = [
|
||||||
|
'id' => $entryId,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'releasedAt' => $releasedAtIso,
|
'releasedAt' => $releasedAtIso,
|
||||||
'executable' => 'PROSERVE_UE_5_5.exe',
|
'executable' => 'PROSERVE_UE_5_5.exe',
|
||||||
'installFolderTemplate' => 'PROSERVE v{version}',
|
'installFolderTemplate' => 'PROSERVE v{version}',
|
||||||
|
'channels' => $channels,
|
||||||
'download' => [
|
'download' => [
|
||||||
'url' => "{$base}/builds/proserve-{$version}.zip",
|
'url' => "{$base}/builds/{$zipFilename}",
|
||||||
'sizeBytes' => 0,
|
'sizeBytes' => 0,
|
||||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||||
],
|
],
|
||||||
'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}",
|
// Release notes adressables par id stable — supporte plusieurs entries
|
||||||
|
// de même version sans collision (chaque release a son propre .md).
|
||||||
|
'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}",
|
||||||
'minLicenseDate' => $minLicDate,
|
'minLicenseDate' => $minLicDate,
|
||||||
'availableForDownload' => $available,
|
'availableForDownload' => $available,
|
||||||
];
|
];
|
||||||
|
if ($isBeta) {
|
||||||
|
$entry['isBeta'] = true;
|
||||||
|
if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes;
|
||||||
|
}
|
||||||
|
$manifest['versions'][] = $entry;
|
||||||
|
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
|
|
||||||
if ($notes !== '') {
|
if ($notes !== '') {
|
||||||
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
||||||
file_put_contents("$notesDir/{$version}.md", $notes);
|
file_put_contents("$notesDir/{$entryId}.md", $notes);
|
||||||
}
|
}
|
||||||
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
|
$message = "v{$version} ajoutée (id <code>{$entryId}</code>). Upload le ZIP <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, puis clique « Sync (sign-manifest) ».";
|
||||||
}
|
}
|
||||||
elseif ($action === 'edit_notes') {
|
elseif ($action === 'edit_notes') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
$notes = $_POST['notes'] ?? '';
|
$notes = $_POST['notes'] ?? '';
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.');
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
||||||
file_put_contents("$notesDir/{$version}.md", $notes);
|
file_put_contents("$notesDir/{$entryId}.md", $notes);
|
||||||
$message = "Release notes de v{$version} mises à jour.";
|
$message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour.";
|
||||||
}
|
}
|
||||||
elseif ($action === 'edit_meta') {
|
elseif ($action === 'edit_meta') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
||||||
$releasedAt = trim($_POST['released_at'] ?? '');
|
$releasedAt = trim($_POST['released_at'] ?? '');
|
||||||
|
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||||||
throw new Exception('min_license_date invalide.');
|
throw new Exception('min_license_date invalide.');
|
||||||
}
|
}
|
||||||
foreach ($manifest['versions'] as &$v) {
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
if ($v['version'] === $version) {
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
$v['minLicenseDate'] = $minLicDate;
|
$version = $manifest['versions'][$idx]['version'] ?? '';
|
||||||
|
|
||||||
|
$manifest['versions'][$idx]['minLicenseDate'] = $minLicDate;
|
||||||
if ($releasedAt !== '') {
|
if ($releasedAt !== '') {
|
||||||
$v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
$manifest['versions'][$idx]['releasedAt'] =
|
||||||
|
(new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
||||||
}
|
}
|
||||||
break;
|
if ($newZipName !== '') {
|
||||||
|
$normalized = ps_normalize_zip_filename($newZipName, $version);
|
||||||
|
// Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry
|
||||||
|
foreach ($manifest['versions'] as $other) {
|
||||||
|
if (($other['id'] ?? '') === $entryId) continue;
|
||||||
|
$otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
|
if ($otherZip === $normalized) {
|
||||||
|
throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($v);
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
|
$manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}";
|
||||||
|
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
||||||
|
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
|
||||||
|
}
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
$message = "Méta de v{$version} mises à jour.";
|
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
|
||||||
|
}
|
||||||
|
elseif ($action === 'set_channels') {
|
||||||
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||||||
|
$manifest['versions'][$idx]['channels'] = $channels;
|
||||||
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
|
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
|
||||||
|
}
|
||||||
|
elseif ($action === 'set_beta') {
|
||||||
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
|
$isBeta = !empty($_POST['is_beta']);
|
||||||
|
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||||
|
if ($isBeta) {
|
||||||
|
$manifest['versions'][$idx]['isBeta'] = true;
|
||||||
|
if ($betaNotes !== null) {
|
||||||
|
$manifest['versions'][$idx]['betaNotes'] = $betaNotes;
|
||||||
|
} else {
|
||||||
|
unset($manifest['versions'][$idx]['betaNotes']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unset($manifest['versions'][$idx]['isBeta']);
|
||||||
|
unset($manifest['versions'][$idx]['betaNotes']);
|
||||||
|
}
|
||||||
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
|
$message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »).";
|
||||||
}
|
}
|
||||||
elseif ($action === 'toggle_available') {
|
elseif ($action === 'toggle_available') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
foreach ($manifest['versions'] as &$v) {
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
if ($v['version'] === $version) {
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
$v['availableForDownload'] = !($v['availableForDownload'] ?? true);
|
$manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($v);
|
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
$message = "Disponibilité de v{$version} mise à jour.";
|
$message = "Disponibilité de v{$version} mise à jour.";
|
||||||
}
|
}
|
||||||
elseif ($action === 'delete') {
|
elseif ($action === 'delete') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
|
// Cleanup release notes file (best effort)
|
||||||
|
$notesFile = "$notesDir/{$entryId}.md";
|
||||||
|
if (is_file($notesFile)) @unlink($notesFile);
|
||||||
$manifest['versions'] = array_values(array_filter(
|
$manifest['versions'] = array_values(array_filter(
|
||||||
$manifest['versions'],
|
$manifest['versions'],
|
||||||
fn($v) => $v['version'] !== $version
|
fn($v) => ($v['id'] ?? '') !== $entryId
|
||||||
));
|
));
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
|
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
|
||||||
@@ -144,39 +309,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (!$result['ok']) $messageType = 'error';
|
if (!$result['ok']) $messageType = 'error';
|
||||||
}
|
}
|
||||||
elseif ($action === 'sync_one') {
|
elseif ($action === 'sync_one') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
if ($version === '') throw new Exception("Version manquante");
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
require_once "$root/tools/SignManifest.php";
|
require_once "$root/tools/SignManifest.php";
|
||||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$force = !empty($_POST['force']);
|
$force = !empty($_POST['force']);
|
||||||
|
// SignManifest::run filtre par numéro de version ; en cas d'entries
|
||||||
|
// de même version sur des channels différents, le hash sera recalculé
|
||||||
|
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
|
||||||
|
// donc le résultat est correct, juste un peu plus de boulot).
|
||||||
$result = $signer->run('versions', $force, $version);
|
$result = $signer->run('versions', $force, $version);
|
||||||
$forceLabel = $force ? ' [FORCE]' : '';
|
$forceLabel = $force ? ' [FORCE]' : '';
|
||||||
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
|
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
|
||||||
if (!$result['ok']) $messageType = 'error';
|
if (!$result['ok']) $messageType = 'error';
|
||||||
}
|
}
|
||||||
elseif ($action === 'set_skip_hash') {
|
elseif ($action === 'set_skip_hash') {
|
||||||
$version = $_POST['version'] ?? '';
|
$entryId = (string)($_POST['id'] ?? '');
|
||||||
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||||
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||||
$skipHash = !empty($_POST['skip']);
|
$skipHash = !empty($_POST['skip']);
|
||||||
foreach ($manifest['versions'] as &$v) {
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||||||
if ($v['version'] === $version) {
|
|
||||||
if ($skipHash) {
|
if ($skipHash) {
|
||||||
$v['download']['hashAlgorithm'] = 'none';
|
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
|
||||||
$v['download']['sha256'] = '';
|
$manifest['versions'][$idx]['download']['sha256'] = '';
|
||||||
} else {
|
} else {
|
||||||
unset($v['download']['hashAlgorithm']);
|
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
|
||||||
if (empty($v['download']['sha256'])) {
|
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
|
||||||
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($v);
|
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
|
|
||||||
// Re-signature auto pour garder le manifest valide après le changement.
|
// Re-signature auto pour garder le manifest valide après le changement.
|
||||||
// En mode skip, on n'a rien à hasher : on appelle run() qui se contentera
|
|
||||||
// de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd.
|
|
||||||
require_once "$root/tools/SignManifest.php";
|
require_once "$root/tools/SignManifest.php";
|
||||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$resign = $signer->run('versions', false);
|
$resign = $signer->run('versions', false);
|
||||||
@@ -193,15 +359,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$manifest = loadManifest($manifestPath);
|
$manifest = loadManifest($manifestPath);
|
||||||
|
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
|
||||||
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
|
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
|
||||||
$zipSizes = [];
|
$zipSizes = [];
|
||||||
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
|
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
|
||||||
|
|
||||||
// Pour l'édition : pré-charge les release notes existantes
|
// Pour l'édition : pré-charge les release notes existantes (indexées par
|
||||||
|
// id stable maintenant). Fallback sur l'ancien format {version}.md pour les
|
||||||
|
// notes créées avant v0.27.1, qu'on n'a pas encore migrées.
|
||||||
$existingNotes = [];
|
$existingNotes = [];
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
$f = "$notesDir/{$v['version']}.md";
|
$id = $v['id'] ?? '';
|
||||||
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
|
$byId = $id !== '' ? "$notesDir/{$id}.md" : null;
|
||||||
|
$byVersion = "$notesDir/{$v['version']}.md";
|
||||||
|
if ($byId !== null && is_file($byId)) {
|
||||||
|
$existingNotes[$id] = file_get_contents($byId);
|
||||||
|
} elseif (is_file($byVersion)) {
|
||||||
|
$existingNotes[$id] = file_get_contents($byVersion);
|
||||||
|
} else {
|
||||||
|
$existingNotes[$id] = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Layout::header('Versions', 'versions');
|
Layout::header('Versions', 'versions');
|
||||||
@@ -213,11 +390,14 @@ Layout::header('Versions', 'versions');
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Workflow d'une nouvelle release</h2>
|
<h2>Workflow d'une nouvelle release</h2>
|
||||||
<ol class="muted">
|
<ol class="muted">
|
||||||
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</li>
|
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).</li>
|
||||||
<li>Upload le ZIP correspondant via SFTP dans <code>www/PS_Launcher/builds/</code> en respectant le nom <code>proserve-{version}.zip</code>.</li>
|
<li>Upload le ZIP en SFTP dans <code>www/PS_Launcher/builds/</code> (le nom doit matcher celui que tu as saisi, par défaut <code>proserve-{version}.zip</code>).</li>
|
||||||
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
|
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
|
||||||
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<p class="muted" style="margin: 12px 0 0;">
|
||||||
|
💡 La gestion des channels (création / édition / suppression) se fait sur la page <a href="channels.php">Channels</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -239,13 +419,41 @@ Layout::header('Versions', 'versions');
|
|||||||
<input type="date" name="min_license_date" required>
|
<input type="date" name="min_license_date" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label>
|
||||||
|
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
|
||||||
|
<?php foreach ($channelRegistry['channels'] as $c):
|
||||||
|
$cn = $c['name'] ?? '';
|
||||||
|
$isDefault = $cn === 'default';
|
||||||
|
?>
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $isDefault ? 'checked' : '' ?>>
|
||||||
|
<code><?= htmlspecialchars($cn) ?></code>
|
||||||
|
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Release notes (Markdown)</label>
|
<label>Release notes (Markdown)</label>
|
||||||
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
|
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="row">
|
||||||
|
<div class="col field">
|
||||||
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col field">
|
||||||
|
<label><input type="checkbox" name="is_beta"> Version BÊTA <span class="muted" style="font-weight: normal; font-size: 11px;">(visible uniquement par les licenses « can_see_betas »)</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal; font-size: 11px;">(affichées en tooltip dans le launcher quand BÊTA est coché)</span></label>
|
||||||
|
<input type="text" name="beta_notes" placeholder="Tester en priorité : nouvelle UI scènes, intégration capteurs.">
|
||||||
|
</div>
|
||||||
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
|
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +484,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
|
<th>Channels</th>
|
||||||
<th>Release</th>
|
<th>Release</th>
|
||||||
<th>min_license</th>
|
<th>min_license</th>
|
||||||
<th>ZIP</th>
|
<th>ZIP</th>
|
||||||
@@ -286,13 +495,29 @@ Layout::header('Versions', 'versions');
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($manifest['versions'] ?? [] as $v):
|
<?php foreach ($manifest['versions'] ?? [] as $v):
|
||||||
$zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
|
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
$zipExists = in_array($zipBase, $zips, true);
|
$zipExists = in_array($zipBase, $zips, true);
|
||||||
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
|
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
|
||||||
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
|
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
// Channels du version : array si défini, sinon implicite ['default']
|
||||||
|
$vChannels = isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])
|
||||||
|
? $v['channels']
|
||||||
|
: ['default'];
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
|
<td>
|
||||||
|
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
|
||||||
|
<?php if (!empty($v['isBeta'])): ?>
|
||||||
|
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php foreach ($vChannels as $ch): ?>
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px;"><?= htmlspecialchars($ch) ?></code>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
|
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
|
||||||
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
|
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -317,7 +542,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="toggle_available">
|
<input type="hidden" name="action" value="toggle_available">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<button class="btn btn-secondary" type="submit">
|
<button class="btn btn-secondary" type="submit">
|
||||||
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
|
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
|
||||||
</button>
|
</button>
|
||||||
@@ -328,7 +553,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
|
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="sync_one">
|
<input type="hidden" name="action" value="sync_one">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -348,7 +573,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="set_skip_hash">
|
<input type="hidden" name="action" value="set_skip_hash">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
||||||
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
||||||
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
||||||
@@ -358,7 +583,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
|
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="sync_one">
|
<input type="hidden" name="action" value="sync_one">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<input type="hidden" name="force" value="1">
|
<input type="hidden" name="force" value="1">
|
||||||
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -367,10 +592,10 @@ Layout::header('Versions', 'versions');
|
|||||||
</details>
|
</details>
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
<summary class="btn btn-secondary">Méta</summary>
|
<summary class="btn btn-secondary">Méta</summary>
|
||||||
<form method="post" style="margin-top: 8px;">
|
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="edit_meta">
|
<input type="hidden" name="action" value="edit_meta">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>min_license_date</label>
|
<label>min_license_date</label>
|
||||||
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||||
@@ -379,6 +604,11 @@ Layout::header('Versions', 'versions');
|
|||||||
<label>Date de release (UTC)</label>
|
<label>Date de release (UTC)</label>
|
||||||
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
|
||||||
|
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||||
|
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
@@ -387,23 +617,65 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post" style="margin-top: 8px;">
|
<form method="post" style="margin-top: 8px;">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="edit_notes">
|
<input type="hidden" name="action" value="edit_notes">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['version']] ?? '') ?></textarea>
|
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
|
||||||
<br><br>
|
<br><br>
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
|
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
|
||||||
|
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_beta">
|
||||||
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||||
|
Cette version est une BÊTA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notes pour les testeurs</label>
|
||||||
|
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||||
|
placeholder="Tester en priorité…">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
|
<summary class="btn btn-secondary">Channels</summary>
|
||||||
|
<form method="post" style="margin-top: 8px; min-width: 280px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channels">
|
||||||
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
|
<div class="field">
|
||||||
|
<?php foreach ($channelRegistry['channels'] as $c):
|
||||||
|
$cn = $c['name'] ?? '';
|
||||||
|
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||||||
|
?>
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||||||
|
<code><?= htmlspecialchars($cn) ?></code>
|
||||||
|
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest après modif.</p>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="delete">
|
<input type="hidden" name="action" value="delete">
|
||||||
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||||
<button class="btn btn-danger" type="submit">Retirer</button>
|
<button class="btn btn-danger" type="submit">Retirer</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($manifest['versions'])): ?>
|
<?php if (empty($manifest['versions'])): ?>
|
||||||
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -420,7 +692,7 @@ Layout::header('Versions', 'versions');
|
|||||||
<?php
|
<?php
|
||||||
$referencedNames = [];
|
$referencedNames = [];
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
$referencedNames[] = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
|
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
}
|
}
|
||||||
foreach ($zips as $z):
|
foreach ($zips as $z):
|
||||||
$referenced = in_array($z, $referencedNames, true);
|
$referenced = in_array($z, $referencedNames, true);
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ $route = trim((string)($_GET['route'] ?? ''), '/');
|
|||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
||||||
|
// Crypto requis : depuis v0.27.0 le manifest est re-signé à la volée
|
||||||
|
// selon le channel demandé (filtrage server-side).
|
||||||
|
require __DIR__ . '/lib/Crypto.php';
|
||||||
require __DIR__ . '/routes/Manifest.php';
|
require __DIR__ . '/routes/Manifest.php';
|
||||||
\PSLauncher\Routes\Manifest::handle($config);
|
\PSLauncher\Routes\Manifest::handle($config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
|
||||||
|
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
||||||
require __DIR__ . '/routes/Releasenotes.php';
|
require __DIR__ . '/routes/Releasenotes.php';
|
||||||
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,21 +3,137 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace PSLauncher\Routes;
|
namespace PSLauncher\Routes;
|
||||||
|
|
||||||
|
use PSLauncher\Crypto;
|
||||||
use PSLauncher\Response;
|
use PSLauncher\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint manifest public. Reçoit ?channel=X depuis le launcher (channel
|
||||||
|
* provenant de la license signée Ed25519). On filtre les versions pour ne
|
||||||
|
* renvoyer que celles taggées sur ce channel ou sur "default", et on re-signe
|
||||||
|
* le manifest filtré à la volée avec la clé privée Ed25519.
|
||||||
|
*
|
||||||
|
* Sémantique additive : un client sur channel "police" voit "default" + "police".
|
||||||
|
* Pour rendre une version invisible au public, ne PAS la tagger "default".
|
||||||
|
*/
|
||||||
final class Manifest
|
final class Manifest
|
||||||
{
|
{
|
||||||
|
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
|
||||||
|
|
||||||
public static function handle(array $config): void
|
public static function handle(array $config): void
|
||||||
{
|
{
|
||||||
$file = dirname(__DIR__, 2) . '/manifest/versions.json';
|
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
|
||||||
if (!is_file($file)) {
|
if (!is_file($manifestPath)) {
|
||||||
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
||||||
}
|
}
|
||||||
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
|
|
||||||
|
$raw = file_get_contents($manifestPath);
|
||||||
|
$manifest = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($manifest)) {
|
||||||
|
Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel demandé par le client. Whitelist regex anti-injection. Un
|
||||||
|
// channel inconnu / mal formé est traité comme NULL → le client voit
|
||||||
|
// uniquement les versions "default".
|
||||||
|
$clientChannel = $_GET['channel'] ?? null;
|
||||||
|
if (!is_string($clientChannel) || $clientChannel === '' || !preg_match(self::CHANNEL_REGEX, $clientChannel)) {
|
||||||
|
$clientChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest['versions'] = self::filterVersions(
|
||||||
|
$manifest['versions'] ?? [],
|
||||||
|
$clientChannel
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
|
||||||
|
// (sert déjà aux licenses), accessible uniquement depuis le serveur.
|
||||||
|
// Coût : ~1ms d'Ed25519 par requête, négligeable vs le réseau.
|
||||||
|
$sk = $config['ed25519']['private_key_hex'] ?? '';
|
||||||
|
if ($sk !== '') {
|
||||||
|
unset($manifest['signature']); // important : canonical sans la signature
|
||||||
|
$manifest['signature'] = Crypto::signEd25519(Crypto::canonicalJson($manifest), $sk);
|
||||||
|
} else {
|
||||||
|
unset($manifest['signature']); // pas de clé = pas de signature, le client échouera la vérif
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Cache court pour absorber les pics. ETag inclut le channel pour que
|
||||||
|
// les caches HTTP intermédiaires séparent bien les vues par client.
|
||||||
header('Cache-Control: public, max-age=300, must-revalidate');
|
header('Cache-Control: public, max-age=300, must-revalidate');
|
||||||
header('ETag: "' . md5_file($file) . '"');
|
header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Content-Length: ' . filesize($file));
|
header('Content-Length: ' . strlen((string)$body));
|
||||||
readfile($file);
|
echo $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre + dédupe par numéro de version.
|
||||||
|
*
|
||||||
|
* Étape 1 — filter : on garde les versions visibles par ce client. Un
|
||||||
|
* client sur channel X voit les entries taggées 'default' OU 'X'.
|
||||||
|
*
|
||||||
|
* Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
|
||||||
|
* fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
|
||||||
|
* seule entry — la plus spécifique au client. Priorité : l'entry taggée
|
||||||
|
* avec le channel propre du client gagne ; à défaut, l'entry default
|
||||||
|
* sert de fallback. Sans cette dédup côté serveur, le launcher recevait
|
||||||
|
* deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
|
||||||
|
* silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
|
||||||
|
* pour un user firefighter.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string,mixed>> $versions
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function filterVersions(array $versions, ?string $clientChannel): array
|
||||||
|
{
|
||||||
|
// Étape 1 : filter visible par ce client
|
||||||
|
$visible = [];
|
||||||
|
foreach ($versions as $v) {
|
||||||
|
$channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
|
||||||
|
? $v['channels']
|
||||||
|
: ['default'];
|
||||||
|
|
||||||
|
$isPublic = in_array('default', $channels, true);
|
||||||
|
$matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);
|
||||||
|
|
||||||
|
if ($isPublic || $matchesUser) {
|
||||||
|
$visible[] = ['entry' => $v, 'channels' => $channels];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étape 2 : group by version, pick most specific per group
|
||||||
|
$byVersion = [];
|
||||||
|
foreach ($visible as $item) {
|
||||||
|
$key = (string)($item['entry']['version'] ?? '?');
|
||||||
|
$byVersion[$key][] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($byVersion as $items) {
|
||||||
|
if (count($items) === 1) {
|
||||||
|
$result[] = $items[0]['entry'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Plusieurs entries pour ce numéro : on cherche d'abord une qui
|
||||||
|
// matche le channel spécifique du client (firefighter, police…),
|
||||||
|
// sinon on retombe sur l'entry default.
|
||||||
|
$specific = null;
|
||||||
|
$defaultEntry = null;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($clientChannel !== null
|
||||||
|
&& $specific === null
|
||||||
|
&& in_array($clientChannel, $item['channels'], true)) {
|
||||||
|
$specific = $item;
|
||||||
|
}
|
||||||
|
if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
|
||||||
|
$defaultEntry = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$picked = $specific ?? $defaultEntry ?? $items[0];
|
||||||
|
$result[] = $picked['entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,27 @@ namespace PSLauncher\Routes;
|
|||||||
|
|
||||||
use PSLauncher\Response;
|
use PSLauncher\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes endpoint. Accepte deux formats d'identifiant :
|
||||||
|
* - id stable d'entrée : 'v' + 8 hex chars (format depuis v0.27.1, supporte
|
||||||
|
* plusieurs entries de même version)
|
||||||
|
* - numéro de version legacy : X.Y.Z (compat ascendante avec les notes
|
||||||
|
* créées avant v0.27.1)
|
||||||
|
*/
|
||||||
final class Releasenotes
|
final class Releasenotes
|
||||||
{
|
{
|
||||||
public static function handle(string $version): void
|
public static function handle(string $key): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
||||||
Response::error('invalid_version', 'Bad version format', 400);
|
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
|
||||||
|
|
||||||
|
if (!$isEntryId && !$isVersion) {
|
||||||
|
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
||||||
}
|
}
|
||||||
$file = dirname(__DIR__, 2) . "/releasenotes/{$version}.md";
|
|
||||||
|
$file = dirname(__DIR__, 2) . "/releasenotes/{$key}.md";
|
||||||
if (!is_file($file)) {
|
if (!is_file($file)) {
|
||||||
Response::error('not_found', "Release notes for {$version} not found", 404);
|
Response::error('not_found', "Release notes for {$key} not found", 404);
|
||||||
}
|
}
|
||||||
header('Cache-Control: public, max-age=3600, must-revalidate');
|
header('Cache-Control: public, max-age=3600, must-revalidate');
|
||||||
header('Content-Type: text/markdown; charset=utf-8');
|
header('Content-Type: text/markdown; charset=utf-8');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class ValidateLicense
|
|||||||
|
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
||||||
max_machines, revoked_at
|
max_machines, channel, can_see_betas, revoked_at
|
||||||
FROM licenses WHERE license_key = ? LIMIT 1'
|
FROM licenses WHERE license_key = ? LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$licenseKey]);
|
$stmt->execute([$licenseKey]);
|
||||||
@@ -82,6 +82,17 @@ final class ValidateLicense
|
|||||||
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
|
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
|
||||||
$expired = strtotime($lic['download_entitlement_until']) < time();
|
$expired = strtotime($lic['download_entitlement_until']) < time();
|
||||||
|
|
||||||
|
// ATTENTION : ordre des clés CRITIQUE pour la signature Ed25519.
|
||||||
|
// Crypto::canonicalJson NE TRIE PAS — il prend l'ordre tel quel. Le client
|
||||||
|
// C# (LicenseService.CanonicalBytesFor) reconstruit le même dictionnaire
|
||||||
|
// dans le même ordre. Toute modif ici doit être miroir côté client.
|
||||||
|
// channel : NULL en DB → null JSON (visible dans le canonical comme "channel":null).
|
||||||
|
// can_see_betas : 0/1 en DB → bool JSON.
|
||||||
|
$channel = isset($lic['channel']) && $lic['channel'] !== null && $lic['channel'] !== ''
|
||||||
|
? (string)$lic['channel']
|
||||||
|
: null;
|
||||||
|
$canSeeBetas = (bool)($lic['can_see_betas'] ?? 0);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'status' => $expired ? 'expired' : 'valid',
|
'status' => $expired ? 'expired' : 'valid',
|
||||||
'licenseId' => 'lic_' . $lic['id'],
|
'licenseId' => 'lic_' . $lic['id'],
|
||||||
@@ -89,6 +100,8 @@ final class ValidateLicense
|
|||||||
'issuedAt' => $issuedAt,
|
'issuedAt' => $issuedAt,
|
||||||
'downloadEntitlementUntil' => $entUntil,
|
'downloadEntitlementUntil' => $entUntil,
|
||||||
'maxMachines' => (int)$lic['max_machines'],
|
'maxMachines' => (int)$lic['max_machines'],
|
||||||
|
'channel' => $channel,
|
||||||
|
'canSeeBetas' => $canSeeBetas,
|
||||||
'serverTime' => $serverTime,
|
'serverTime' => $serverTime,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
50
server/migrations/002_channel_betas.sql
Normal file
50
server/migrations/002_channel_betas.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- PS_Launcher schema v2
|
||||||
|
-- Ajoute deux flags par-license :
|
||||||
|
-- * channel : nom du manifest à servir (NULL = default = manifest/versions.json)
|
||||||
|
-- * can_see_betas : autorise la license à voir les versions taggées isBeta=true
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCE : OVH mutualisé peut tourner sur MariaDB pré-10.0.2 où
|
||||||
|
-- ALTER TABLE ... IF NOT EXISTS n'existe pas. On contourne avec une PROCEDURE
|
||||||
|
-- temporaire qui consulte INFORMATION_SCHEMA avant chaque ALTER. Comme ça la
|
||||||
|
-- migration peut être re-jouée sans erreur sur n'importe quelle version
|
||||||
|
-- MariaDB / MySQL 5.5+.
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS ps_launcher_migrate_002;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE ps_launcher_migrate_002()
|
||||||
|
BEGIN
|
||||||
|
-- channel
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'channel'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- can_see_betas
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'can_see_betas'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- index sur channel (une simple recherche bénéficie peu mais c'est pas cher)
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND INDEX_NAME = 'idx_channel'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE licenses ADD INDEX idx_channel (channel);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL ps_launcher_migrate_002();
|
||||||
|
DROP PROCEDURE ps_launcher_migrate_002;
|
||||||
|
|
||||||
|
-- Note : pas de migration de data. Les licenses existantes gardent
|
||||||
|
-- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas).
|
||||||
|
-- L'admin attribue manuellement les flags via la page licenses.php.
|
||||||
@@ -179,6 +179,11 @@ public partial class App : Application
|
|||||||
new ManifestService(
|
new ManifestService(
|
||||||
sp.GetRequiredService<HttpClient>(),
|
sp.GetRequiredService<HttpClient>(),
|
||||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||||
|
// Channel provider : lit le channel depuis la license cachée à
|
||||||
|
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
|
||||||
|
// dépendance circulaire avec ILicenseService au démarrage).
|
||||||
|
// Si pas de license cachée → null → manifest default.
|
||||||
|
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
||||||
sp.GetRequiredService<IManifestCache>(),
|
sp.GetRequiredService<IManifestCache>(),
|
||||||
sp.GetRequiredService<IPeerManifestFetcher>(),
|
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.25.10</Version>
|
<Version>0.27.2</Version>
|
||||||
<AssemblyVersion>0.25.10.0</AssemblyVersion>
|
<AssemblyVersion>0.27.2.0</AssemblyVersion>
|
||||||
<FileVersion>0.25.10.0</FileVersion>
|
<FileVersion>0.27.2.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -513,7 +513,17 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
|
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
|
||||||
|
|
||||||
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
||||||
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
|
||||||
|
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
|
||||||
|
// les versions taggées isBeta=true. Les installations locales déjà
|
||||||
|
// présentes ne sont pas filtrées (un client qui avait installé une
|
||||||
|
// bêta puis perd l'accès continue de la voir et de pouvoir la lancer
|
||||||
|
// — on n'efface pas son disque).
|
||||||
|
var canSeeBetas = _license?.CanSeeBetas ?? false;
|
||||||
|
var rawRemote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
||||||
|
var remote = canSeeBetas
|
||||||
|
? rawRemote
|
||||||
|
: rawRemote.Where(v => !v.IsBeta).ToList();
|
||||||
var remoteByVer = remote.ToDictionary(v => v.Version);
|
var remoteByVer = remote.ToDictionary(v => v.Version);
|
||||||
|
|
||||||
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public VersionManifest? Remote { get; }
|
public VersionManifest? Remote { get; }
|
||||||
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si la version est taggée BÊTA dans le manifest (champ
|
||||||
|
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
||||||
|
/// orange "BÊTA" + tooltip avec <see cref="BetaNotes"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBeta => Remote?.IsBeta ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Note des testeurs (texte libre). Affichée en tooltip quand on hover
|
||||||
|
/// le badge BÊTA. Null si non renseigné côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public string? BetaNotes => Remote?.BetaNotes;
|
||||||
|
|
||||||
|
/// <summary>Tooltip composé pour le hover sur le badge BÊTA.</summary>
|
||||||
|
public string BetaTooltip => string.IsNullOrEmpty(BetaNotes)
|
||||||
|
? Strings.BetaBadgeTooltipDefault
|
||||||
|
: Strings.BetaBadgeTooltip(BetaNotes!);
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(StateLabel))]
|
[NotifyPropertyChangedFor(nameof(StateLabel))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||||
|
|||||||
@@ -85,6 +85,17 @@
|
|||||||
FontSize="15" FontWeight="SemiBold"
|
FontSize="15" FontWeight="SemiBold"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
|
||||||
|
que celui du featured mais en plus petit pour matcher la row. -->
|
||||||
|
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#F59E0B"
|
||||||
|
ToolTip="{Binding BetaTooltip}"
|
||||||
|
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||||
|
FontSize="10" FontWeight="Bold"
|
||||||
|
Foreground="#1A0A00" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
@@ -472,6 +483,18 @@
|
|||||||
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
|
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
|
||||||
FontSize="32" FontWeight="Bold"
|
FontSize="32" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<!-- Badge BÊTA : pill orange à côté de la version. Visible
|
||||||
|
uniquement quand isBeta=true dans le manifest. Tooltip
|
||||||
|
dynamique avec les notes des testeurs s'il y en a. -->
|
||||||
|
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#F59E0B"
|
||||||
|
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
|
||||||
|
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="#1A0A00" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ public sealed class LicenseService : ILicenseService
|
|||||||
OwnerName = _config.License.CachedOwnerName,
|
OwnerName = _config.License.CachedOwnerName,
|
||||||
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
||||||
ServerTime = lastValidation,
|
ServerTime = lastValidation,
|
||||||
|
// Restauration des champs channel + canSeeBetas (ajoutés persistants
|
||||||
|
// depuis v0.27.2). Sans ça, le channelProvider du ManifestService
|
||||||
|
// retombait sur null à chaque relance, le client fetch sans
|
||||||
|
// ?channel=, et l'utilisateur ne voyait que les versions default —
|
||||||
|
// jamais les builds spécifiques à son channel (firefighter, police…).
|
||||||
|
Channel = _config.License.CachedChannel,
|
||||||
|
CanSeeBetas = _config.License.CachedCanSeeBetas,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +234,8 @@ public sealed class LicenseService : ILicenseService
|
|||||||
_config.License.CachedOwnerName = response.OwnerName;
|
_config.License.CachedOwnerName = response.OwnerName;
|
||||||
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
|
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
|
||||||
_config.License.CachedStatus = response.Status;
|
_config.License.CachedStatus = response.Status;
|
||||||
|
_config.License.CachedChannel = response.Channel;
|
||||||
|
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +319,10 @@ public sealed class LicenseService : ILicenseService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
||||||
{
|
{
|
||||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
|
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
|
||||||
|
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||||
|
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||||
|
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||||
var dict = new Dictionary<string, object?>
|
var dict = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["status"] = response.Status,
|
["status"] = response.Status,
|
||||||
@@ -319,6 +331,8 @@ public sealed class LicenseService : ILicenseService
|
|||||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||||
["maxMachines"] = response.MaxMachines,
|
["maxMachines"] = response.MaxMachines,
|
||||||
|
["channel"] = response.Channel, // null si default
|
||||||
|
["canSeeBetas"] = response.CanSeeBetas,
|
||||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
};
|
};
|
||||||
var opts = new JsonSerializerOptions
|
var opts = new JsonSerializerOptions
|
||||||
@@ -347,8 +361,18 @@ public sealed class LicenseService : ILicenseService
|
|||||||
var pkBytes = Convert.FromHexString(publicKeyHex);
|
var pkBytes = Convert.FromHexString(publicKeyHex);
|
||||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||||
var sig = Convert.FromBase64String(base64Signature);
|
var sig = Convert.FromBase64String(base64Signature);
|
||||||
var payload = CanonicalBytesFor(response);
|
|
||||||
return alg.Verify(pk, payload, sig);
|
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
||||||
|
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
||||||
|
// le format des réponses signées par les serveurs OU lancées par
|
||||||
|
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
||||||
|
// cachées localement avant l'update à v0.26 deviendraient invalides
|
||||||
|
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
||||||
|
var payloadCurrent = CanonicalBytesFor(response);
|
||||||
|
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
||||||
|
|
||||||
|
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||||
|
return alg.Verify(pk, payloadLegacy, sig);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -356,6 +380,33 @@ public sealed class LicenseService : ILicenseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
|
||||||
|
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
|
||||||
|
/// les anciennes versions du client. Une fois que toutes les licenses
|
||||||
|
/// auront été re-validées online avec le nouveau serveur, on pourra
|
||||||
|
/// retirer cette méthode (mais on prévient pas de coût à la garder).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["status"] = response.Status,
|
||||||
|
["licenseId"] = response.LicenseId,
|
||||||
|
["ownerName"] = response.OwnerName,
|
||||||
|
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||||
|
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||||
|
["maxMachines"] = response.MaxMachines,
|
||||||
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
|
};
|
||||||
|
var opts = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Public key embarquée -----
|
// ----- Public key embarquée -----
|
||||||
|
|
||||||
private static string? TryReadEmbeddedPublicKey()
|
private static string? TryReadEmbeddedPublicKey()
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ public static class Strings
|
|||||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
||||||
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
||||||
|
|
||||||
|
// ==================== BETA BADGE ====================
|
||||||
|
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
|
||||||
|
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي");
|
||||||
|
|
||||||
|
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
|
||||||
|
public static string BetaBadgeTooltipDefault => T(
|
||||||
|
"Version BÊTA — utilisée pour les tests internes. Vos retours nous aident à la valider avant publication officielle.",
|
||||||
|
"BETA version — used for internal testing. Your feedback helps us validate it before official release.",
|
||||||
|
"测试版 — 用于内部测试。您的反馈有助于我们在正式发布前验证此版本。",
|
||||||
|
"เวอร์ชันเบต้า — ใช้สำหรับการทดสอบภายใน ความคิดเห็นของคุณช่วยให้เรายืนยันก่อนการเปิดตัวอย่างเป็นทางการ",
|
||||||
|
"إصدار تجريبي — يُستخدم للاختبار الداخلي. ملاحظاتك تساعدنا في التحقق منه قبل الإصدار الرسمي."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Tooltip avec les notes spécifiques fournies par l'admin côté serveur.</summary>
|
||||||
|
public static string BetaBadgeTooltip(string testerNotes) => T(
|
||||||
|
$"Version BÊTA — {testerNotes}",
|
||||||
|
$"BETA version — {testerNotes}",
|
||||||
|
$"测试版 — {testerNotes}",
|
||||||
|
$"เวอร์ชันเบต้า — {testerNotes}",
|
||||||
|
$"إصدار تجريبي — {testerNotes}"
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== ACTIONS ====================
|
// ==================== ACTIONS ====================
|
||||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||||
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class ManifestService : IManifestService
|
|||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly Func<string> _serverBaseUrlProvider;
|
private readonly Func<string> _serverBaseUrlProvider;
|
||||||
|
private readonly Func<string?> _channelProvider;
|
||||||
private readonly IManifestCache _manifestCache;
|
private readonly IManifestCache _manifestCache;
|
||||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||||
private readonly ILogger<ManifestService> _logger;
|
private readonly ILogger<ManifestService> _logger;
|
||||||
@@ -26,12 +27,14 @@ public sealed class ManifestService : IManifestService
|
|||||||
public ManifestService(
|
public ManifestService(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
Func<string> serverBaseUrlProvider,
|
Func<string> serverBaseUrlProvider,
|
||||||
|
Func<string?> channelProvider,
|
||||||
IManifestCache manifestCache,
|
IManifestCache manifestCache,
|
||||||
IPeerManifestFetcher peerManifestFetcher,
|
IPeerManifestFetcher peerManifestFetcher,
|
||||||
ILogger<ManifestService> logger)
|
ILogger<ManifestService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||||
|
_channelProvider = channelProvider;
|
||||||
_manifestCache = manifestCache;
|
_manifestCache = manifestCache;
|
||||||
_peerManifestFetcher = peerManifestFetcher;
|
_peerManifestFetcher = peerManifestFetcher;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -116,7 +119,15 @@ public sealed class ManifestService : IManifestService
|
|||||||
|
|
||||||
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
|
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
// Channel = manifest filter par-license. NULL ou vide = on appelle
|
||||||
|
// /manifest tout court (= versions.json default). Sinon /manifest?channel=X
|
||||||
|
// (le serveur charge versions-{X}.json avec fallback sur default si fichier absent).
|
||||||
|
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
|
||||||
|
var channel = _channelProvider();
|
||||||
|
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||||
|
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")
|
||||||
|
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}"
|
||||||
|
: baseUrl;
|
||||||
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ public sealed class LicenseValidationResponse
|
|||||||
[JsonPropertyName("maxMachines")]
|
[JsonPropertyName("maxMachines")]
|
||||||
public int? MaxMachines { get; set; }
|
public int? MaxMachines { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel de manifest associé à la license. NULL = manifest default
|
||||||
|
/// (versions.json). Sinon le launcher fetch versions-{Channel}.json. Le
|
||||||
|
/// champ est inclus dans la signature Ed25519, donc un user ne peut pas
|
||||||
|
/// modifier sa réponse cachée pour basculer sur un autre channel.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("channel")]
|
||||||
|
public string? Channel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si la license autorise l'affichage des versions taggées
|
||||||
|
/// <c>isBeta=true</c> dans le launcher. Le filtrage est côté client (le
|
||||||
|
/// serveur peut servir le manifest complet — la signature Ed25519
|
||||||
|
/// protège contre toute modif locale du flag).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("canSeeBetas")]
|
||||||
|
public bool CanSeeBetas { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("serverTime")]
|
[JsonPropertyName("serverTime")]
|
||||||
public DateTime? ServerTime { get; set; }
|
public DateTime? ServerTime { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,22 @@ public sealed class LicenseConfig
|
|||||||
public DateTime? CachedEntitlementUntil { get; set; }
|
public DateTime? CachedEntitlementUntil { get; set; }
|
||||||
public string? CachedOwnerName { get; set; }
|
public string? CachedOwnerName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel de distribution attribué à cette license côté serveur (NULL =
|
||||||
|
/// aucun ⇒ accès uniquement aux versions taggées 'default'). Persisté ici
|
||||||
|
/// pour que <see cref="ILicenseService.GetCached"/> restaure ce champ entre
|
||||||
|
/// deux lancements du launcher — sans ça, le channelProvider du
|
||||||
|
/// ManifestService retombait sur null à chaque relance et le client
|
||||||
|
/// fetchait toujours le manifest default.
|
||||||
|
/// </summary>
|
||||||
|
public string? CachedChannel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flag <c>canSeeBetas</c> de la license. Persisté pour la même raison
|
||||||
|
/// que <see cref="CachedChannel"/> : restauration entre deux lancements.
|
||||||
|
/// </summary>
|
||||||
|
public bool CachedCanSeeBetas { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
|
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
|
||||||
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une
|
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une
|
||||||
|
|||||||
@@ -70,6 +70,37 @@ public sealed class VersionManifest
|
|||||||
[JsonPropertyName("availableForDownload")]
|
[JsonPropertyName("availableForDownload")]
|
||||||
public bool AvailableForDownload { get; set; } = true;
|
public bool AvailableForDownload { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channels qui voient cette version. Liste vide ou champ absent ⇒
|
||||||
|
/// implicitement <c>["default"]</c> (= visible par tous les clients,
|
||||||
|
/// peu importe leur channel).
|
||||||
|
///
|
||||||
|
/// La sémantique est additive côté serveur : si une license est sur
|
||||||
|
/// channel "police", elle reçoit (déjà filtrées par le serveur) les
|
||||||
|
/// versions taggées "default" + celles taggées "police". Le client n'a
|
||||||
|
/// pas besoin de re-filtrer côté UI — Manifest.php a déjà fait le tri,
|
||||||
|
/// signé le résultat. On expose quand même la liste ici pour debug et
|
||||||
|
/// pour un éventuel affichage admin du tag dans l'UI.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("channels")]
|
||||||
|
public List<string> Channels { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
|
||||||
|
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
|
||||||
|
/// voient cette version, les autres l'ignorent (dans <c>RebuildList</c>).
|
||||||
|
/// Default false → comportement standard (visible par tous).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("isBeta")]
|
||||||
|
public bool IsBeta { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Note libre destinée aux testeurs, affichée en tooltip quand on hover le
|
||||||
|
/// badge BÊTA. Optionnel.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("betaNotes")]
|
||||||
|
public string? BetaNotes { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("heroImageUrl")]
|
[JsonPropertyName("heroImageUrl")]
|
||||||
public string? HeroImageUrl { get; set; }
|
public string? HeroImageUrl { get; set; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user