v0.27.0 — Channels comme TAGS sur versions (refactor du modèle)
Changement majeur du modèle de channels en réponse au feedback :
« c'est trop compliqué. Il faudrait juste pouvoir tout laisser dans
builds, et taguer une version dans un canal ou un autre voir plusieurs
canaux. ». Le précédent design (1 fichier manifest par channel +
sous-dossier builds/{channel}/) imposait une duplication de structure
et empêchait une version d'être visible sur plusieurs channels à la fois.
NOUVEAU MODÈLE :
- 1 seul fichier manifest/versions.json
- 1 seul dossier builds/ flat (les ZIPs ont des noms libres pour éviter
les collisions homonymes — proserve-1.5.3.zip, proserve-1.5.3-police.zip…)
- Chaque entry de version a un champ `channels: ["default", "police"]`
qui dit qui peut la voir
- Sémantique additive : un client sur channel "police" voit les versions
taggées "default" + celles taggées "police". "default" = public.
- Une version peut être taggée sur plusieurs channels en une fois.
NOUVEAU REGISTRE channels.json :
Le registre des channels (avec name + label + description) vit dans
manifest/channels.json. Le channel "default" est implicite et ne peut
pas être supprimé. Nouvelle page admin Channels (CRUD) pour gérer la
liste, avec garde-fou anti-suppression : compte les versions taggées
et les licenses attribuées avant d'autoriser la suppression.
SERVEUR :
- api/routes/Manifest.php : reçoit ?channel=X depuis la license,
filtre versions où channels[] contient X ou "default", re-signe avec
la clé privée Ed25519 à la volée. Coût ~1ms par requête.
- admin/versions.php : refactor — plus de session "channel actif", plus
de switcher en haut, plus de prefix builds/{channel}/. Add form a
des checkboxes channels[]. Nouveau bouton "Channels" par-row pour
retager.
- admin/licenses.php : dropdown channel alimenté depuis channels.json
au lieu de scanner les fichiers manifest.
- tools/SignManifest.php : revert du constructeur channel-aware,
toujours sur versions.json + builds/ flat.
CLIENT :
- VersionManifest.Channels (List<string>) ajouté pour debug, mais le
filtrage est server-side donc le client n'a rien à faire de ce champ
côté UX.
- L'envoi du ?channel=X depuis la license signée fonctionne déjà
depuis v0.26.0, pas de changement nécessaire.
MIGRATION :
Les fichiers manifest/versions-{X}.json créés en v0.26.0 deviennent
inutiles. Si tu en avais (test "police" par exemple), copie les
versions concernées dans versions.json et tague-les avec les bons
channels via la nouvelle UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.26.0"
|
#define MyAppVersion "0.27.0"
|
||||||
#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();
|
||||||
@@ -28,23 +30,22 @@ function generateLicenseKey(): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste les channels disponibles en scannant manifest/versions-*.json.
|
* Liste les channels disponibles depuis le registre channels.json. Le channel
|
||||||
* Le channel "default" est toujours présent (= manifest/versions.json).
|
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
|
||||||
* Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest.
|
* « pas de tag, voit que default »).
|
||||||
*/
|
*/
|
||||||
function listAvailableChannels(): array
|
function listAvailableChannels(): array
|
||||||
{
|
{
|
||||||
$dir = dirname(__DIR__) . '/manifest';
|
$manifestDir = dirname(__DIR__) . '/manifest';
|
||||||
$channels = ['' => '(default)']; // clé vide = NULL en DB
|
$registry = Channels::load($manifestDir);
|
||||||
if (is_dir($dir)) {
|
$out = ['' => '(aucun — voit uniquement « default »)'];
|
||||||
foreach (glob($dir . '/versions-*.json') as $file) {
|
foreach ($registry['channels'] as $c) {
|
||||||
$name = basename($file);
|
$name = (string)($c['name'] ?? '');
|
||||||
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
|
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
|
||||||
$channels[$m[1]] = $m[1];
|
$label = $c['label'] ?? $name;
|
||||||
}
|
$out[$name] = "{$name} — {$label}";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return $channels;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
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';
|
||||||
@@ -13,63 +15,30 @@ $root = dirname(__DIR__);
|
|||||||
$manifestDir = "$root/manifest";
|
$manifestDir = "$root/manifest";
|
||||||
$buildsDir = "$root/builds";
|
$buildsDir = "$root/builds";
|
||||||
$notesDir = "$root/releasenotes";
|
$notesDir = "$root/releasenotes";
|
||||||
|
$manifestPath = "$manifestDir/versions.json";
|
||||||
// Channel actif pour cette session d'édition. ?channel=X bascule sur
|
|
||||||
// versions-X.json (créé à la volée si nécessaire), vide = manifest default.
|
|
||||||
//
|
|
||||||
// Normalisation tolérante : on lowercase, on remplace espaces et tirets-cadratins
|
|
||||||
// par des '-', on retire tout le reste. Comme ça l'admin peut taper "ASTERION VR"
|
|
||||||
// dans le champ et ça devient "asterion-vr" — pas de submit silencieusement
|
|
||||||
// bloqué par une regex stricte côté HTML (ce qui faisait rien quand on cliquait
|
|
||||||
// "Aller" en v0.26.0).
|
|
||||||
function ps_normalize_channel(string $raw): string
|
|
||||||
{
|
|
||||||
$s = strtolower(trim($raw));
|
|
||||||
// Espaces, tirets typo, tabs, etc. → tiret simple
|
|
||||||
$s = preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
|
|
||||||
// Retire tout caractère non whitelisté
|
|
||||||
$s = preg_replace('/[^a-z0-9_-]/', '', $s);
|
|
||||||
// Compresse les tirets multiples + trim de tirets en début/fin
|
|
||||||
$s = preg_replace('/-+/', '-', $s);
|
|
||||||
$s = trim($s, '-_');
|
|
||||||
return substr($s, 0, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
$channelRaw = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? ''));
|
|
||||||
$channelNormalized = ps_normalize_channel($channelRaw);
|
|
||||||
$channelWasNormalized = $channelRaw !== '' && $channelNormalized !== $channelRaw;
|
|
||||||
$channelEmptyAfterNorm = $channelRaw !== '' && $channelNormalized === '';
|
|
||||||
|
|
||||||
$channel = $channelNormalized;
|
|
||||||
$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json";
|
|
||||||
$manifestPath = "$manifestDir/$manifestFileName";
|
|
||||||
|
|
||||||
$message = null; $messageType = 'success';
|
$message = null; $messageType = 'success';
|
||||||
if ($channelEmptyAfterNorm) {
|
|
||||||
$message = "Nom de channel inexploitable « {$channelRaw} » (que des caractères non autorisés). Tape un nom avec des lettres / chiffres, ex. asterion-vr.";
|
// Liste des channels déclarés dans channels.json (incluant "default" implicite)
|
||||||
$messageType = 'error';
|
$channelRegistry = Channels::load($manifestDir);
|
||||||
} elseif ($channelWasNormalized) {
|
$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']);
|
||||||
$message = "Nom de channel normalisé : « {$channelRaw} » → « {$channelNormalized} ».";
|
|
||||||
$messageType = 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste les channels existants (= versions-*.json présents) + ajoute toujours
|
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
|
||||||
* 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire
|
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
|
||||||
* "Créer un channel" — le fichier sera créé au premier 'add'.
|
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
|
||||||
*/
|
*/
|
||||||
function listExistingChannels(string $manifestDir): array
|
function sanitizeChannels(array $posted, array $allowedNames): array
|
||||||
{
|
{
|
||||||
$channels = ['' => '(default)'];
|
$out = [];
|
||||||
if (is_dir($manifestDir)) {
|
foreach ($posted as $c) {
|
||||||
foreach (glob($manifestDir . '/versions-*.json') as $file) {
|
$c = (string)$c;
|
||||||
$name = basename($file);
|
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
|
||||||
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
|
$out[] = $c;
|
||||||
$channels[$m[1]] = $m[1];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $channels;
|
if (empty($out)) $out = ['default'];
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadManifest(string $path): array
|
function loadManifest(string $path): array
|
||||||
@@ -139,20 +108,21 @@ 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';
|
||||||
// Pour un channel non-default, le ZIP vit dans builds/{channel}/.
|
// Tous les ZIPs vivent dans builds/ flat. Le nom du fichier est libre :
|
||||||
$zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}";
|
// par défaut proserve-{version}.zip, l'admin peut surcharger pour des
|
||||||
// Le nom du fichier ZIP est libre : par défaut proserve-{version}.zip,
|
// builds homonymes (proserve-1.4.6-police.zip, proserve-1.4.6-asterion.zip…).
|
||||||
// mais l'admin peut surcharger pour distinguer des builds homonymes
|
|
||||||
// entre channels (ex. proserve-1.4.6-asterion.zip vs proserve-1.4.6-foo.zip)
|
|
||||||
// ou pour un naming custom du fournisseur.
|
|
||||||
$zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
$zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
||||||
|
// Tags : liste des channels qui voient cette version. Sanitized contre
|
||||||
|
// le registre — un channel inconnu est silencieusement filtré. Vide → ['default'].
|
||||||
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||||||
$entry = [
|
$entry = [
|
||||||
'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}/{$zipPathPrefix}/{$zipFilename}",
|
'url' => "{$base}/builds/{$zipFilename}",
|
||||||
'sizeBytes' => 0,
|
'sizeBytes' => 0,
|
||||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||||
],
|
],
|
||||||
@@ -172,7 +142,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
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/{$version}.md", $notes);
|
||||||
}
|
}
|
||||||
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
|
$message = "v{$version} ajoutée. 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'] ?? '';
|
$version = $_POST['version'] ?? '';
|
||||||
@@ -201,20 +171,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
if ($newZipName !== '') {
|
if ($newZipName !== '') {
|
||||||
$normalized = ps_normalize_zip_filename($newZipName, $version);
|
$normalized = ps_normalize_zip_filename($newZipName, $version);
|
||||||
// Reconstruit l'URL en remplaçant juste le filename, pas
|
// Reconstruit l'URL : tous les ZIPs sont à la racine de builds/
|
||||||
// le path. Permet de garder un éventuel sous-dossier custom
|
// donc on remplace juste le filename, sans sous-dossier.
|
||||||
// tout en renommant le ZIP. Force aussi la re-vérif du sha256
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
// au prochain Sync (le ZIP cherché change physiquement).
|
$v['download']['url'] = "{$base}/builds/{$normalized}";
|
||||||
$oldUrl = $v['download']['url'] ?? '';
|
// Invalide l'ancien hash : un nouveau ZIP est attendu
|
||||||
$parsed = parse_url($oldUrl);
|
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
||||||
if ($parsed !== false && isset($parsed['path'])) {
|
$v['download']['sizeBytes'] = 0;
|
||||||
$newPath = rtrim(dirname($parsed['path']), '/') . '/' . $normalized;
|
|
||||||
$newUrl = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? 'asterionvr.com') . $newPath;
|
|
||||||
$v['download']['url'] = $newUrl;
|
|
||||||
// Invalide l'ancien hash : un nouveau ZIP est attendu
|
|
||||||
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
|
||||||
$v['download']['sizeBytes'] = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -223,6 +186,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
|
$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') {
|
||||||
|
$version = $_POST['version'] ?? '';
|
||||||
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||||||
|
foreach ($manifest['versions'] as &$v) {
|
||||||
|
if ($v['version'] === $version) {
|
||||||
|
$v['channels'] = $channels;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($v);
|
||||||
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
|
||||||
|
}
|
||||||
elseif ($action === 'set_beta') {
|
elseif ($action === 'set_beta') {
|
||||||
$version = $_POST['version'] ?? '';
|
$version = $_POST['version'] ?? '';
|
||||||
$isBeta = !empty($_POST['is_beta']);
|
$isBeta = !empty($_POST['is_beta']);
|
||||||
@@ -270,7 +246,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
elseif ($action === 'sync' || $action === 'sync_versions') {
|
elseif ($action === 'sync' || $action === 'sync_versions') {
|
||||||
require_once "$root/tools/SignManifest.php";
|
require_once "$root/tools/SignManifest.php";
|
||||||
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$scope = $action === 'sync_versions' ? 'versions' : 'all';
|
$scope = $action === 'sync_versions' ? 'versions' : 'all';
|
||||||
$force = !empty($_POST['force']);
|
$force = !empty($_POST['force']);
|
||||||
$result = $signer->run($scope, $force);
|
$result = $signer->run($scope, $force);
|
||||||
@@ -283,7 +259,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$version = $_POST['version'] ?? '';
|
$version = $_POST['version'] ?? '';
|
||||||
if ($version === '') throw new Exception("Version manquante");
|
if ($version === '') throw new Exception("Version manquante");
|
||||||
require_once "$root/tools/SignManifest.php";
|
require_once "$root/tools/SignManifest.php";
|
||||||
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$force = !empty($_POST['force']);
|
$force = !empty($_POST['force']);
|
||||||
$result = $signer->run('versions', $force, $version);
|
$result = $signer->run('versions', $force, $version);
|
||||||
$forceLabel = $force ? ' [FORCE]' : '';
|
$forceLabel = $force ? ' [FORCE]' : '';
|
||||||
@@ -314,7 +290,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// En mode skip, on n'a rien à hasher : on appelle run() qui se contentera
|
// 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.
|
// 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, $channel ?: null);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$resign = $signer->run('versions', false);
|
$resign = $signer->run('versions', false);
|
||||||
$message = ($skipHash
|
$message = ($skipHash
|
||||||
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
|
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
|
||||||
@@ -329,23 +305,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$manifest = loadManifest($manifestPath);
|
$manifest = loadManifest($manifestPath);
|
||||||
// Liste des ZIPs : on inclut builds/ + builds/{channel}/ (1 niveau de
|
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
|
||||||
// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les
|
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
|
||||||
// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs
|
|
||||||
// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip).
|
|
||||||
$zips = [];
|
|
||||||
$zipSizes = [];
|
$zipSizes = [];
|
||||||
if (is_dir($buildsDir)) {
|
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
|
||||||
foreach (array_merge(
|
|
||||||
glob("$buildsDir/*.zip") ?: [],
|
|
||||||
glob("$buildsDir/*/*.zip") ?: []
|
|
||||||
) as $z) {
|
|
||||||
$rel = ltrim(str_replace($buildsDir, '', $z), '/\\');
|
|
||||||
$rel = str_replace('\\', '/', $rel);
|
|
||||||
$zips[] = $rel;
|
|
||||||
$zipSizes[$rel] = filesize($z);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pour l'édition : pré-charge les release notes existantes
|
// Pour l'édition : pré-charge les release notes existantes
|
||||||
$existingNotes = [];
|
$existingNotes = [];
|
||||||
@@ -354,66 +317,23 @@ foreach ($manifest['versions'] ?? [] as $v) {
|
|||||||
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
|
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingChannels = listExistingChannels($manifestDir);
|
|
||||||
// On force la présence du channel actif dans la liste, même si versions-X.json
|
|
||||||
// n'existe pas encore (cas où l'utilisateur vient de "créer" un channel et n'y
|
|
||||||
// a pas encore ajouté de version). Sinon le dropdown afficherait "(default)"
|
|
||||||
// alors que la session est sur le nouveau channel — confusion classique.
|
|
||||||
if ($channel !== '' && !isset($existingChannels[$channel])) {
|
|
||||||
$existingChannels[$channel] = $channel . ' (vide — pas encore de versions)';
|
|
||||||
}
|
|
||||||
$channelLabel = $channel === '' ? 'default' : $channel;
|
|
||||||
|
|
||||||
Layout::header('Versions', 'versions');
|
Layout::header('Versions', 'versions');
|
||||||
?>
|
?>
|
||||||
<h1>Versions <span class="muted" style="font-size: 14px; font-weight: normal;">— channel actif : <code style="background: rgba(0,0,0,0.3); padding: 2px 8px; border-radius: 4px;"><?= htmlspecialchars($channelLabel) ?></code></span></h1>
|
<h1>Versions</h1>
|
||||||
|
|
||||||
<?php Layout::flash($message, $messageType); ?>
|
<?php Layout::flash($message, $messageType); ?>
|
||||||
|
|
||||||
<!--
|
|
||||||
Channel switcher : bascule la session admin sur un autre fichier manifest.
|
|
||||||
Toutes les actions (add/edit/delete/sync) qui suivent opéreront sur ce channel.
|
|
||||||
Le hidden __channel propage la sélection à chaque POST pour ne pas perdre le
|
|
||||||
contexte au refresh.
|
|
||||||
-->
|
|
||||||
<div class="card" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
|
||||||
<strong>Channel à éditer :</strong>
|
|
||||||
<form method="get" style="display: inline-flex; gap: 6px; margin: 0;">
|
|
||||||
<select name="channel" onchange="this.form.submit()">
|
|
||||||
<?php foreach ($existingChannels as $value => $label):
|
|
||||||
$selected = $value === $channel ? 'selected' : '';
|
|
||||||
?>
|
|
||||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
<span class="muted" style="font-size: 12px;">
|
|
||||||
Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel.
|
|
||||||
</span>
|
|
||||||
<form method="get" style="display: inline-flex; gap: 6px; margin-left: auto;">
|
|
||||||
<input type="text" name="channel" placeholder="Créer/utiliser un nouveau channel : asterion-vr"
|
|
||||||
required maxlength="80" style="width: 280px;"
|
|
||||||
title="Sera normalisé en lowercase + tirets côté serveur (ex. « ASTERION VR » → « asterion-vr »).">
|
|
||||||
<button class="btn btn-secondary" type="submit">Aller</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 en respectant le nom <code>proserve-{version}.zip</code>, dans le dossier qui correspond au channel actif :
|
<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>
|
||||||
<ul>
|
|
||||||
<?php if ($channel === ''): ?>
|
|
||||||
<li>Channel <strong>default</strong> → <code>www/PS_Launcher/builds/proserve-{version}.zip</code></li>
|
|
||||||
<?php else: ?>
|
|
||||||
<li>Channel <strong><?= htmlspecialchars($channel) ?></strong> → <code>www/PS_Launcher/builds/<?= htmlspecialchars($channel) ?>/proserve-{version}.zip</code> <span class="muted">(crée le sous-dossier en SFTP s'il n'existe pas)</span></li>
|
|
||||||
<?php endif; ?>
|
|
||||||
</ul>
|
|
||||||
</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">
|
||||||
@@ -421,7 +341,6 @@ Layout::header('Versions', 'versions');
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="add">
|
<input type="hidden" name="action" value="add">
|
||||||
<input type="hidden" name="__channel" value="<?= htmlspecialchars($channel) ?>">
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version (X.Y.Z)</label>
|
<label>Version (X.Y.Z)</label>
|
||||||
@@ -437,8 +356,23 @@ Layout::header('Versions', 'versions');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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> ; utile pour distinguer un build homonyme entre channels)</span></label>
|
<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-asterion.zip" maxlength="120">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Release notes (Markdown)</label>
|
<label>Release notes (Markdown)</label>
|
||||||
@@ -486,6 +420,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>
|
||||||
@@ -496,19 +431,17 @@ Layout::header('Versions', 'versions');
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($manifest['versions'] ?? [] as $v):
|
<?php foreach ($manifest['versions'] ?? [] as $v):
|
||||||
// Format relatif depuis builds/. Pour le default channel, ça donne juste
|
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
// "proserve-X.Y.Z.zip" ; pour les autres channels, "asterion-vr/proserve-X.Y.Z.zip".
|
$zipExists = in_array($zipBase, $zips, true);
|
||||||
// Doit matcher exactement ce que produit $zips (cf. plus haut, glob sur 1
|
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
|
||||||
// niveau de sous-dossier max + chemin relatif normalisé en slash forward).
|
|
||||||
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
|
|
||||||
$zipPos = strpos($urlPath, '/builds/');
|
|
||||||
$zipRel = $zipPos !== false
|
|
||||||
? ltrim(substr($urlPath, $zipPos + strlen('/builds/')), '/\\')
|
|
||||||
: basename($urlPath);
|
|
||||||
$zipExists = in_array($zipRel, $zips, true);
|
|
||||||
$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>
|
<td>
|
||||||
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
|
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
|
||||||
@@ -516,15 +449,20 @@ Layout::header('Versions', 'versions');
|
|||||||
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
|
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</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>
|
||||||
<?php if ($zipExists): ?>
|
<?php if ($zipExists): ?>
|
||||||
<span class="badge badge-success">présent</span>
|
<span class="badge badge-success">présent</span>
|
||||||
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipRel] ?? 0) ?></span>
|
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-warning">absent</span>
|
<span class="badge badge-warning">absent</span>
|
||||||
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipRel) ?></code></div>
|
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -641,6 +579,28 @@ Layout::header('Versions', 'versions');
|
|||||||
<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>
|
<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>
|
</form>
|
||||||
</details>
|
</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="version" value="<?= htmlspecialchars($v['version']) ?>">
|
||||||
|
<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">
|
||||||
@@ -651,7 +611,7 @@ Layout::header('Versions', 'versions');
|
|||||||
</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>
|
||||||
@@ -666,18 +626,12 @@ Layout::header('Versions', 'versions');
|
|||||||
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
|
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php
|
<?php
|
||||||
// On référence avec le chemin relatif après /builds/ pour matcher
|
$referencedNames = [];
|
||||||
// les ZIPs en sous-dossier (channels). Ex. asterion-vr/proserve-1.4.6.zip.
|
|
||||||
$referencedRel = [];
|
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
|
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||||||
$pos = strpos($urlPath, '/builds/');
|
|
||||||
if ($pos !== false) {
|
|
||||||
$referencedRel[] = ltrim(substr($urlPath, $pos + strlen('/builds/')), '/\\');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
foreach ($zips as $z):
|
foreach ($zips as $z):
|
||||||
$referenced = in_array($z, $referencedRel, true);
|
$referenced = in_array($z, $referencedNames, true);
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code><?= htmlspecialchars($z) ?></code></td>
|
<td><code><?= htmlspecialchars($z) ?></code></td>
|
||||||
@@ -691,22 +645,4 @@ Layout::header('Versions', 'versions');
|
|||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
// Propage le channel actif à toutes les formes POST qui n'ont pas explicitement
|
|
||||||
// leur propre __channel — évite d'éditer chaque form individuellement et
|
|
||||||
// préserve le contexte channel après chaque submit (sinon on retomberait sur
|
|
||||||
// le default à la prochaine action).
|
|
||||||
(function() {
|
|
||||||
var ch = <?= json_encode($channel) ?>;
|
|
||||||
document.querySelectorAll('form[method="post"]').forEach(function(form) {
|
|
||||||
if (!form.querySelector('input[name="__channel"]')) {
|
|
||||||
var inp = document.createElement('input');
|
|
||||||
inp.type = 'hidden';
|
|
||||||
inp.name = '__channel';
|
|
||||||
inp.value = ch;
|
|
||||||
form.appendChild(inp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<?php Layout::footer();
|
<?php Layout::footer();
|
||||||
|
|||||||
@@ -3,41 +3,90 @@ 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
|
||||||
{
|
{
|
||||||
/// Whitelist du paramètre ?channel=X : on autorise [a-z0-9_-]{1,64}
|
|
||||||
/// pour empêcher tout path traversal (..) ou injection de caractères exotiques.
|
|
||||||
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
|
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
|
||||||
|
|
||||||
public static function handle(array $config): void
|
public static function handle(array $config): void
|
||||||
{
|
{
|
||||||
$manifestDir = dirname(__DIR__, 2) . '/manifest';
|
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
|
||||||
$defaultFile = $manifestDir . '/versions.json';
|
if (!is_file($manifestPath)) {
|
||||||
|
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
||||||
$channel = $_GET['channel'] ?? null;
|
|
||||||
$resolved = $defaultFile;
|
|
||||||
|
|
||||||
if (is_string($channel) && $channel !== '' && preg_match(self::CHANNEL_REGEX, $channel)) {
|
|
||||||
$candidate = $manifestDir . '/versions-' . $channel . '.json';
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
$resolved = $candidate;
|
|
||||||
}
|
|
||||||
// Sinon : silencieux, on retombe sur versions.json. Évite de leak la
|
|
||||||
// liste exhaustive des channels par probing.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_file($resolved)) {
|
$raw = file_get_contents($manifestPath);
|
||||||
Response::error('manifest_missing', 'manifest not found on server', 404);
|
$manifest = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($manifest)) {
|
||||||
|
Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version.
|
// Channel demandé par le client. Whitelist regex anti-injection. Un
|
||||||
// ETag inclut le nom du fichier servi pour qu'un changement de channel cassé l'ancien cache HTTP.
|
// 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(basename($resolved) . '|' . md5_file($resolved)) . '"');
|
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($resolved));
|
header('Content-Length: ' . strlen((string)$body));
|
||||||
readfile($resolved);
|
echo $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string,mixed>> $versions
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function filterVersions(array $versions, ?string $clientChannel): array
|
||||||
|
{
|
||||||
|
$filtered = [];
|
||||||
|
foreach ($versions as $v) {
|
||||||
|
// Si pas de channels sur l'entry → implicitement "default"
|
||||||
|
$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) {
|
||||||
|
$filtered[] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($filtered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,9 @@ final class SignManifest
|
|||||||
/** @var string[] */
|
/** @var string[] */
|
||||||
public array $log = [];
|
public array $log = [];
|
||||||
|
|
||||||
/**
|
public function __construct(string $rootDir)
|
||||||
* @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/).
|
|
||||||
* @param string|null $channel Channel à signer. NULL/'' = manifest default
|
|
||||||
* (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}.
|
|
||||||
* Le hashcache est partagé entre tous les channels (les ZIPs peuvent être
|
|
||||||
* référencés par plusieurs manifests, économise les recalculs).
|
|
||||||
*/
|
|
||||||
public function __construct(string $rootDir, ?string $channel = null)
|
|
||||||
{
|
{
|
||||||
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
|
$this->manifestPath = $rootDir . '/manifest/versions.json';
|
||||||
? "versions-{$channel}.json"
|
|
||||||
: 'versions.json';
|
|
||||||
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
|
|
||||||
$this->buildsDir = $rootDir . '/builds';
|
$this->buildsDir = $rootDir . '/builds';
|
||||||
$this->configPath = $rootDir . '/api/config.php';
|
$this->configPath = $rootDir . '/api/config.php';
|
||||||
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
||||||
@@ -41,32 +31,6 @@ final class SignManifest
|
|||||||
|
|
||||||
private function out(string $line): void { $this->log[] = $line; }
|
private function out(string $line): void { $this->log[] = $line; }
|
||||||
|
|
||||||
/**
|
|
||||||
* Mappe le chemin URL d'un ZIP (ex. /PS_Launcher/builds/asterion-vr/proserve-1.4.6.zip)
|
|
||||||
* vers son chemin local sur disque (ex. {$buildsDir}/asterion-vr/proserve-1.4.6.zip).
|
|
||||||
*
|
|
||||||
* Stratégie : on cherche le segment '/builds/' dans le URL path et on prend
|
|
||||||
* tout ce qui vient après comme chemin relatif depuis $buildsDir. Sécurité :
|
|
||||||
* on neutralise '..' pour empêcher path traversal, même si l'URL est déjà
|
|
||||||
* sous notre contrôle (manifest signé Ed25519).
|
|
||||||
*
|
|
||||||
* Si le pattern '/builds/' n'est pas trouvé, fallback sur basename comme avant
|
|
||||||
* (compat ascendante avec d'éventuelles URLs custom).
|
|
||||||
*/
|
|
||||||
private function resolveZipPath(string $urlPath): string
|
|
||||||
{
|
|
||||||
$marker = '/builds/';
|
|
||||||
$pos = strpos($urlPath, $marker);
|
|
||||||
if ($pos === false) {
|
|
||||||
return "{$this->buildsDir}/" . basename($urlPath);
|
|
||||||
}
|
|
||||||
$relative = substr($urlPath, $pos + strlen($marker));
|
|
||||||
// Sécu : strip '..' segments pour éviter path traversal hors de buildsDir
|
|
||||||
$relative = str_replace(['..\\', '../', '..'], '', $relative);
|
|
||||||
$relative = ltrim($relative, '/\\');
|
|
||||||
return "{$this->buildsDir}/$relative";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
||||||
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
||||||
@@ -176,29 +140,19 @@ final class SignManifest
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le ZIP peut vivre dans builds/ directement (channel default) OU dans
|
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
|
||||||
// builds/{channel}/ pour les autres channels. On extrait donc TOUT le
|
$zip = "{$this->buildsDir}/$filename";
|
||||||
// chemin après /builds/ dans l'URL plutôt qu'un simple basename().
|
|
||||||
$urlPath = parse_url($url, PHP_URL_PATH) ?: '';
|
|
||||||
$filename = basename($urlPath);
|
|
||||||
$zip = $this->resolveZipPath($urlPath);
|
|
||||||
|
|
||||||
if (!is_file($zip)) {
|
if (!is_file($zip)) {
|
||||||
// Fallback : recherche fuzzy sur la version dans builds/ + sous-dossiers
|
$candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [];
|
||||||
// immédiats (1 niveau) au cas où l'admin aurait renommé le ZIP.
|
|
||||||
$candidates = array_merge(
|
|
||||||
glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [],
|
|
||||||
glob("{$this->buildsDir}/*/*{$version}*.zip", GLOB_NOSORT) ?: []
|
|
||||||
);
|
|
||||||
$candidates = array_values(array_filter($candidates, 'is_file'));
|
$candidates = array_values(array_filter($candidates, 'is_file'));
|
||||||
if (count($candidates) === 1) {
|
if (count($candidates) === 1) {
|
||||||
$zip = $candidates[0];
|
$zip = $candidates[0];
|
||||||
$this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . str_replace($this->buildsDir . '/', '', $zip) . "' à la place");
|
$this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place");
|
||||||
} else {
|
} else {
|
||||||
$this->out(" [skip] $version : ZIP introuvable pour $url");
|
$this->out(" [skip] $version : ZIP introuvable pour $url");
|
||||||
$this->out(" Cherché : " . str_replace($this->buildsDir . '/', '', $zip));
|
|
||||||
if (count($candidates) > 1) {
|
if (count($candidates) > 1) {
|
||||||
$this->out(" Plusieurs candidats : " . implode(', ', array_map(fn($c) => str_replace($this->buildsDir . '/', '', $c), $candidates)));
|
$this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.26.0</Version>
|
<Version>0.27.0</Version>
|
||||||
<AssemblyVersion>0.26.0.0</AssemblyVersion>
|
<AssemblyVersion>0.27.0.0</AssemblyVersion>
|
||||||
<FileVersion>0.26.0.0</FileVersion>
|
<FileVersion>0.27.0.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>
|
||||||
|
|||||||
@@ -70,6 +70,21 @@ 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>
|
/// <summary>
|
||||||
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
|
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
|
||||||
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
|
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
|
||||||
|
|||||||
Reference in New Issue
Block a user