DEUX features liées :
1. CHANNELS : chaque license peut être attribuée à un manifest distinct
(« channel »). Permet de servir des versions différentes selon le
client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
avec fallback transparent sur versions.json. NULL = default.
2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
Visible uniquement par les licenses avec can_see_betas=1. Affichage
d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
les testeurs. Les installations locales déjà présentes restent
visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
le disque du client).
DB :
002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.
Serveur PHP :
- ValidateLicense.php signe channel + canSeeBetas dans la réponse
(ordre des clés CRITIQUE pour matcher le canonical client).
- Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
silencieux sur versions.json si channel inconnu (évite leak de la
liste de channels par probing).
- SignManifest.php prend un channel optionnel → l'admin peut signer
chaque manifest indépendamment.
- admin/licenses.php : dropdown channel + checkbox bêta sur create,
bouton détails repliable par-row pour edit.
- admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
row, dialog repliable « Bêta » avec checkbox + notes des testeurs.
Client C# :
- License.Channel + License.CanSeeBetas (dans le canonical signé).
- VersionManifest.IsBeta + BetaNotes.
- ManifestService prend un channelProvider via DI, lu depuis license
cachée à chaque fetch (lazy, pas de circular dep).
- MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
(mais conserve les installées locales — on ne retire pas l'accès
rétroactivement à ce qui est déjà sur disque).
- VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
- MainWindow.xaml : pill orange à côté du n° version pour le featured
et les rows compactes, tooltip dynamique avec les notes testeurs.
Backward compat signature :
Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
toujours validées via un fallback canonical legacy dans VerifySignature.
Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
hors-ligne et bloquerait les users en mobilité.
Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
44 lines
1.6 KiB
PHP
44 lines
1.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace PSLauncher\Routes;
|
|
|
|
use PSLauncher\Response;
|
|
|
|
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.
|
|
}
|
|
|
|
if (!is_file($resolved)) {
|
|
Response::error('manifest_missing', 'manifest not found on server', 404);
|
|
}
|
|
|
|
// 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.
|
|
header('Cache-Control: public, max-age=300, must-revalidate');
|
|
header('ETag: "' . md5(basename($resolved) . '|' . md5_file($resolved)) . '"');
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Content-Length: ' . filesize($resolved));
|
|
readfile($resolved);
|
|
}
|
|
}
|