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:
2026-05-05 08:51:10 +02:00
parent 0c69bb1c85
commit 49a3b855af
10 changed files with 559 additions and 276 deletions

231
server/admin/channels.php Normal file
View 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();

View 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']);
}
}

View File

@@ -38,6 +38,7 @@ HTML;
'dashboard' => ['index.php', 'Dashboard'],
'licenses' => ['licenses.php', 'Licenses'],
'versions' => ['versions.php', 'Versions'],
'channels' => ['channels.php', 'Channels'],
'launcher' => ['launcher.php', 'Launcher'],
'audit' => ['audit.php', 'Audit'],
];

View File

@@ -2,10 +2,12 @@
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Db;
Auth::requireLogin();
@@ -28,23 +30,22 @@ function generateLicenseKey(): string
}
/**
* Liste les channels disponibles en scannant manifest/versions-*.json.
* Le channel "default" est toujours présent (= manifest/versions.json).
* Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest.
* 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
{
$dir = dirname(__DIR__) . '/manifest';
$channels = ['' => '(default)']; // clé vide = NULL en DB
if (is_dir($dir)) {
foreach (glob($dir . '/versions-*.json') as $file) {
$name = basename($file);
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
$channels[$m[1]] = $m[1];
}
}
$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 $channels;
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

View File

@@ -2,9 +2,11 @@
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';
@@ -13,63 +15,30 @@ $root = dirname(__DIR__);
$manifestDir = "$root/manifest";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
// 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";
$manifestPath = "$manifestDir/versions.json";
$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.";
$messageType = 'error';
} elseif ($channelWasNormalized) {
$message = "Nom de channel normalisé : « {$channelRaw} » → « {$channelNormalized} ».";
$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']);
/**
* Liste les channels existants (= versions-*.json présents) + ajoute toujours
* 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire
* "Créer un channel" — le fichier sera créé au premier 'add'.
* 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 listExistingChannels(string $manifestDir): array
function sanitizeChannels(array $posted, array $allowedNames): array
{
$channels = ['' => '(default)'];
if (is_dir($manifestDir)) {
foreach (glob($manifestDir . '/versions-*.json') as $file) {
$name = basename($file);
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
$channels[$m[1]] = $m[1];
}
$out = [];
foreach ($posted as $c) {
$c = (string)$c;
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
$out[] = $c;
}
}
return $channels;
if (empty($out)) $out = ['default'];
return $out;
}
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');
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
// Pour un channel non-default, le ZIP vit dans builds/{channel}/.
$zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}";
// Le nom du fichier ZIP est libre : par défaut proserve-{version}.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.
// Tous les ZIPs vivent dans builds/ flat. Le nom du fichier est libre :
// par défaut proserve-{version}.zip, l'admin peut surcharger pour des
// builds homonymes (proserve-1.4.6-police.zip, proserve-1.4.6-asterion.zip…).
$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 = [
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'PROSERVE v{version}',
'channels' => $channels,
'download' => [
'url' => "{$base}/{$zipPathPrefix}/{$zipFilename}",
'url' => "{$base}/builds/{$zipFilename}",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
@@ -172,7 +142,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
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') {
$version = $_POST['version'] ?? '';
@@ -201,20 +171,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($newZipName !== '') {
$normalized = ps_normalize_zip_filename($newZipName, $version);
// Reconstruit l'URL en remplaçant juste le filename, pas
// le path. Permet de garder un éventuel sous-dossier custom
// tout en renommant le ZIP. Force aussi la re-vérif du sha256
// au prochain Sync (le ZIP cherché change physiquement).
$oldUrl = $v['download']['url'] ?? '';
$parsed = parse_url($oldUrl);
if ($parsed !== false && isset($parsed['path'])) {
$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;
}
// Reconstruit l'URL : tous les ZIPs sont à la racine de builds/
// donc on remplace juste le filename, sans sous-dossier.
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$v['download']['url'] = "{$base}/builds/{$normalized}";
// Invalide l'ancien hash : un nouveau ZIP est attendu
$v['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$v['download']['sizeBytes'] = 0;
}
break;
}
@@ -223,6 +186,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
saveManifest($manifestPath, $manifest);
$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') {
$version = $_POST['version'] ?? '';
$isBeta = !empty($_POST['is_beta']);
@@ -270,7 +246,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
elseif ($action === 'sync' || $action === 'sync_versions') {
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';
$force = !empty($_POST['force']);
$result = $signer->run($scope, $force);
@@ -283,7 +259,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$version = $_POST['version'] ?? '';
if ($version === '') throw new Exception("Version manquante");
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
$result = $signer->run('versions', $force, $version);
$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
// de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd.
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);
$message = ($skipHash
? "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);
// Liste des ZIPs : on inclut builds/ + builds/{channel}/ (1 niveau de
// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les
// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs
// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip).
$zips = [];
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
$zipSizes = [];
if (is_dir($buildsDir)) {
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);
}
}
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
// Pour l'édition : pré-charge les release notes existantes
$existingNotes = [];
@@ -354,66 +317,23 @@ foreach ($manifest['versions'] ?? [] as $v) {
$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');
?>
<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); ?>
<!--
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">
<h2>Workflow d'une nouvelle release</h2>
<ol class="muted">
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</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 :
<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>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).</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>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>
<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 class="card">
@@ -421,7 +341,6 @@ Layout::header('Versions', 'versions');
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="add">
<input type="hidden" name="__channel" value="<?= htmlspecialchars($channel) ?>">
<div class="row">
<div class="col field">
<label>Version (X.Y.Z)</label>
@@ -437,8 +356,23 @@ Layout::header('Versions', 'versions');
</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> ; utile pour distinguer un build homonyme entre channels)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-asterion.zip" maxlength="120">
<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">
<label>Release notes (Markdown)</label>
@@ -486,6 +420,7 @@ Layout::header('Versions', 'versions');
<thead>
<tr>
<th>Version</th>
<th>Channels</th>
<th>Release</th>
<th>min_license</th>
<th>ZIP</th>
@@ -496,19 +431,17 @@ Layout::header('Versions', 'versions');
</thead>
<tbody>
<?php foreach ($manifest['versions'] ?? [] as $v):
// Format relatif depuis builds/. Pour le default channel, ça donne juste
// "proserve-X.Y.Z.zip" ; pour les autres channels, "asterion-vr/proserve-X.Y.Z.zip".
// Doit matcher exactement ce que produit $zips (cf. plus haut, glob sur 1
// 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');
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
$zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$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>
<td>
<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>
<?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($v['minLicenseDate'] ?? '—') ?></td>
<td>
<?php if ($zipExists): ?>
<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: ?>
<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; ?>
</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>
</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="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.)')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
@@ -651,7 +611,7 @@ Layout::header('Versions', 'versions');
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</table>
@@ -666,18 +626,12 @@ Layout::header('Versions', 'versions');
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
<tbody>
<?php
// On référence avec le chemin relatif après /builds/ pour matcher
// les ZIPs en sous-dossier (channels). Ex. asterion-vr/proserve-1.4.6.zip.
$referencedRel = [];
$referencedNames = [];
foreach ($manifest['versions'] ?? [] as $v) {
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$pos = strpos($urlPath, '/builds/');
if ($pos !== false) {
$referencedRel[] = ltrim(substr($urlPath, $pos + strlen('/builds/')), '/\\');
}
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedRel, true);
$referenced = in_array($z, $referencedNames, true);
?>
<tr>
<td><code><?= htmlspecialchars($z) ?></code></td>
@@ -691,22 +645,4 @@ Layout::header('Versions', 'versions');
</table>
<?php endif; ?>
</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();

View File

@@ -3,41 +3,90 @@ declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Crypto;
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
{
/// 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}$/';
public static function handle(array $config): void
{
$manifestDir = dirname(__DIR__, 2) . '/manifest';
$defaultFile = $manifestDir . '/versions.json';
$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.
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'versions.json not found on server', 404);
}
if (!is_file($resolved)) {
Response::error('manifest_missing', 'manifest not found on server', 404);
$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);
}
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version.
// ETag inclut le nom du fichier servi pour qu'un changement de channel cassé l'ancien cache HTTP.
// 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('ETag: "' . md5(basename($resolved) . '|' . md5_file($resolved)) . '"');
header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . filesize($resolved));
readfile($resolved);
header('Content-Length: ' . strlen((string)$body));
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);
}
}

View File

@@ -17,19 +17,9 @@ final class SignManifest
/** @var string[] */
public array $log = [];
/**
* @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)
public function __construct(string $rootDir)
{
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
? "versions-{$channel}.json"
: 'versions.json';
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
$this->manifestPath = $rootDir . '/manifest/versions.json';
$this->buildsDir = $rootDir . '/builds';
$this->configPath = $rootDir . '/api/config.php';
// 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; }
/**
* 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
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
@@ -176,29 +140,19 @@ final class SignManifest
continue;
}
// Le ZIP peut vivre dans builds/ directement (channel default) OU dans
// builds/{channel}/ pour les autres channels. On extrait donc TOUT le
// 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);
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
$zip = "{$this->buildsDir}/$filename";
if (!is_file($zip)) {
// Fallback : recherche fuzzy sur la version dans builds/ + sous-dossiers
// 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 = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [];
$candidates = array_values(array_filter($candidates, 'is_file'));
if (count($candidates) === 1) {
$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 {
$this->out(" [skip] $version : ZIP introuvable pour $url");
$this->out(" Cherché : " . str_replace($this->buildsDir . '/', '', $zip));
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;
}