Files
PS_Launcher/server/api/routes/Manifest.php
j.foucher 67678fe173 Manifest : dédup par version, l'entry channel-spécifique gagne sur default
Cas reporté : un client firefighter recevait DEUX entries v1.5.3 (default
+ firefighter) parce que la sémantique additive est correcte pour la
visibilité, mais ne dédupe pas. Le launcher faisait alors un
ToDictionary(v => v.Version) qui collisionne sur "1.5.3" et garde
silencieusement le premier (la default), donc le mauvais ZIP s'affichait
dans la UI.

Fix : Manifest.php groupe par version après le filtre, et pour chaque
groupe à plusieurs entries pick l'entry la plus spécifique au client :
1. priorité à celle taggée avec le channel propre du client (firefighter)
2. sinon l'entry default sert de fallback

Sémantique pour un user firefighter :
- v1.5.3 (default) + v1.5.3 (firefighter) → renvoie uniquement firefighter
- v1.4.0 (default uniquement) → renvoie default (visible)
- v1.5.3 (firefighter uniquement) → renvoie firefighter

Fix purement server-side, pas de modif client requise. Le launcher
recevra naturellement un seul ZIP par version, le bon.

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

140 lines
5.7 KiB
PHP

<?php
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
{
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
public static function handle(array $config): void
{
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'versions.json 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);
}
// 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(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen((string)$body));
echo $body;
}
/**
* Filtre + dédupe par numéro de version.
*
* Étape 1 — filter : on garde les versions visibles par ce client. Un
* client sur channel X voit les entries taggées 'default' OU 'X'.
*
* Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
* fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
* seule entry — la plus spécifique au client. Priorité : l'entry taggée
* avec le channel propre du client gagne ; à défaut, l'entry default
* sert de fallback. Sans cette dédup côté serveur, le launcher recevait
* deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
* silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
* pour un user firefighter.
*
* @param array<int, array<string,mixed>> $versions
* @return list<array<string,mixed>>
*/
private static function filterVersions(array $versions, ?string $clientChannel): array
{
// Étape 1 : filter visible par ce client
$visible = [];
foreach ($versions as $v) {
$channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
? $v['channels']
: ['default'];
$isPublic = in_array('default', $channels, true);
$matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);
if ($isPublic || $matchesUser) {
$visible[] = ['entry' => $v, 'channels' => $channels];
}
}
// Étape 2 : group by version, pick most specific per group
$byVersion = [];
foreach ($visible as $item) {
$key = (string)($item['entry']['version'] ?? '?');
$byVersion[$key][] = $item;
}
$result = [];
foreach ($byVersion as $items) {
if (count($items) === 1) {
$result[] = $items[0]['entry'];
continue;
}
// Plusieurs entries pour ce numéro : on cherche d'abord une qui
// matche le channel spécifique du client (firefighter, police…),
// sinon on retombe sur l'entry default.
$specific = null;
$defaultEntry = null;
foreach ($items as $item) {
if ($clientChannel !== null
&& $specific === null
&& in_array($clientChannel, $item['channels'], true)) {
$specific = $item;
}
if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
$defaultEntry = $item;
}
}
$picked = $specific ?? $defaultEntry ?? $items[0];
$result[] = $picked['entry'];
}
return array_values($result);
}
}