Bug rapporté après v1.0.10 : client sur license channel=full avec les deux entries v1.5.4.32 dans le backoffice (Entry #1 tagged firefighter+full, Entry #2 tagged full), le launcher n'affichait toujours qu'une seule ligne. Root cause côté serveur, PAS côté client cette fois. Manifest.php faisait un « group by version + pick most specific » dans filterVersions() — comportement historique introduit pour ne pas faire crasher les vieux clients qui déduisaient par ToDictionary(v.Version). Résultat : Entry #2 était filtrée avant même d'atteindre le launcher. Ce filtrage était nécessaire à l'époque (vieux clients) mais bloque tous les fixes multi-channels client (v1.0.8-1.0.10) qui avaient rendu le client capable d'afficher plusieurs entries au même numéro. Fix : opt-in via query param sur /manifest?multiChannel=1 Server (Manifest.php) : • filterVersions() prend un `bool $multiChannel = false`. • Si true → skip group-by, retourne toutes les entries visibles. • Si false (défaut, vieux clients) → comportement historique préservé. • Query param `multiChannel` lu depuis $_GET, transmis à filterVersions. Client (ManifestService.FetchFromOvhAsync) : • Ajoute `?multiChannel=1` inconditionnellement. Un serveur ancien ignore silencieusement le param (pas de header d'échec). • Combiné avec &channel=X quand la license a un channel. Rétro-compat : • Vieux client (v1.0.9-) + serveur nouveau : n'envoie pas multiChannel=1, serveur dédupe comme avant, launcher ne crash pas. • Client nouveau (v1.0.11+) + serveur ancien : le param est ignoré, même comportement qu'avant (dédup côté serveur, une seule row visible). • Client nouveau + serveur nouveau (config voulue) : les deux entries remontent, le row-key-par-Id de v1.0.10 fait le reste. Bump : 1.0.10 → 1.0.11 (fix ciblé serveur+client). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
6.9 KiB
PHP
159 lines
6.9 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;
|
|
}
|
|
|
|
// Multi-channel opt-in : les clients v1.0.10+ savent afficher plusieurs
|
|
// entries au même numéro de version (channels distincts). Ils passent
|
|
// ?multiChannel=1 pour signaler la capabilité. Les clients v1.0.9- (et
|
|
// avant refactor row-by-Id) crasheraient sur ToDictionary(v.Version) →
|
|
// le serveur reste en mode dédup pour eux (comportement historique).
|
|
$multiChannel = !empty($_GET['multiChannel']);
|
|
|
|
$manifest['versions'] = self::filterVersions(
|
|
$manifest['versions'] ?? [],
|
|
$clientChannel,
|
|
$multiChannel
|
|
);
|
|
|
|
// 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, bool $multiChannel = false): 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];
|
|
}
|
|
}
|
|
|
|
// Client v1.0.10+ : bypass la dédup, retourne toutes les entries visibles.
|
|
// Le client sait maintenant afficher plusieurs rows au même numéro (keyage
|
|
// par entryId côté RebuildList) et bloque les collisions d'install côté
|
|
// guard. Le badge channel n'apparaît que si plusieurs entries partagent
|
|
// le numéro (v1.0.9+) → UX propre.
|
|
if ($multiChannel) {
|
|
return array_map(fn($item) => $item['entry'], $visible);
|
|
}
|
|
|
|
// Étape 2 (clients v1.0.9-) : group by version, pick most specific per
|
|
// group. Comportement historique pour ne pas crasher les vieux clients
|
|
// qui font ToDictionary(v.Version) et exploseraient sur des duplicates.
|
|
$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);
|
|
}
|
|
}
|