diff --git a/server/README.md b/server/README.md index d9271c1..2052d25 100644 --- a/server/README.md +++ b/server/README.md @@ -8,59 +8,111 @@ Contenu de ce dossier à uploader **sous `www/PS_Launcher/`** sur le mutualisé www/ └── PS_Launcher/ ├── .htaccess - ├── api/ - │ ├── config.php ← À ÉDITER avec tes vraies valeurs (DB, secrets) + ├── api/ ← API consommée par le launcher + │ ├── config.php ← À CRÉER (copie de config.example.php) │ ├── index.php - │ ├── lib/Response.php - │ └── routes/ - │ ├── Manifest.php - │ └── Releasenotes.php - ├── manifest/ - │ └── versions.json ← Régénéré par tools/sign-manifest.php - ├── releasenotes/ - │ ├── 1.4.5.md - │ └── 1.4.6.md - ├── builds/ ← Tu uploades ici les ZIPs en SFTP - │ └── proserve-1.4.6.zip + │ ├── lib/{Response,Db,Crypto}.php + │ └── routes/{Manifest,Releasenotes,ValidateLicense}.php + ├── admin/ ← Backoffice web (auth par mot de passe) + │ ├── .htaccess + │ ├── index.php (dashboard) + │ ├── login.php / logout.php + │ ├── licenses.php / versions.php / audit.php + │ ├── lib/{Auth,Layout}.php + │ └── assets/style.css + ├── manifest/versions.json + ├── releasenotes/1.4.X.md + ├── builds/ ← ZIPs uploadés en SFTP + ├── migrations/001_init.sql ← Schéma MySQL initial └── tools/ - └── sign-manifest.php ← Lance après chaque upload de ZIP + ├── generate-keypair.php + ├── issue-license.php + └── sign-manifest.php ``` -## Workflow de release +## Setup initial (à faire une fois) -1. Packager Unreal → produit le dossier `Proserve v1.4.6/`. -2. Le zipper localement : le contenu du ZIP doit reproduire ce qui est attendu côté client (le client extrait dans `Proserve v1.4.6/`, donc le ZIP peut soit contenir un dossier racine `Proserve v1.4.6/`, soit son contenu directement — voir ci-dessous). -3. Uploader le ZIP via SFTP dans `www/PS_Launcher/builds/proserve-1.4.6.zip`. -4. Éditer `manifest/versions.json` pour ajouter (ou modifier) l'entrée `1.4.6` et le champ `latest`. -5. Ajouter `releasenotes/1.4.6.md`. -6. Se connecter en SSH OVH et lancer : - ``` - cd www/PS_Launcher - php tools/sign-manifest.php - ``` - Cela calcule automatiquement `sizeBytes` et `sha256` à partir du ZIP. +### 1. Crée la base MySQL +Manager OVH → Hébergements → Bases de données → Créer. Note le DSN, user, password. + +### 2. Joue le schéma +PhpMyAdmin (manager OVH) ou en SSH : +```bash +mysql -h -u -p < www/PS_Launcher/migrations/001_init.sql +``` + +### 3. Configure le serveur +- Copie `api/config.example.php` → `api/config.php` +- Remplis la section `db`, `base_url` +- Génère les clés Ed25519 : + ```bash + cd ~/www/PS_Launcher + php tools/generate-keypair.php + ``` + Recopie `private_key_hex` et `public_key_hex` dans `api/config.php` section `ed25519`. + Recopie aussi `public_key_hex` dans `src/PSLauncher.Core/Resources/server-pubkey.txt` côté projet C# et recompile le launcher. + +### 4. Configure le mot de passe admin +```bash +php -r "echo password_hash('TonMotDePasseFort', PASSWORD_DEFAULT);" +``` +Recopie le hash dans `api/config.php` → `admin_password_hash`. + +### 5. Test la chaîne +``` +https://tondomaine.com/PS_Launcher/api/health → JSON status: ok +https://tondomaine.com/PS_Launcher/admin/login.php → page de connexion admin +``` + +## Workflow de release (via le backoffice) + +1. Connecte-toi sur `https://tondomaine.com/PS_Launcher/admin/`. +2. Onglet **Versions** → formulaire « Ajouter une version » : numéro, date min de license, release notes Markdown. +3. Upload le ZIP correspondant via SFTP dans `www/PS_Launcher/builds/proserve-{version}.zip`. +4. Bouton **🔁 Sync (sign-manifest)** : calcule SHA-256, met à jour `latest`, signe le manifest avec Ed25519. +5. Au prochain « Vérifier les MAJ » côté launcher, la nouvelle version apparaît. + +## Workflow de release (alternative manuelle SSH) + +```bash +# 1. Édite manifest/versions.json (ou utilise le backoffice) +# 2. Upload le ZIP en SFTP dans builds/ +# 3. Resigne : +cd ~/www/PS_Launcher +php tools/sign-manifest.php +``` + +## Émettre une license + +**Via le backoffice** (recommandé) : onglet **Licenses** → formulaire « Émettre ». La clé apparaît une seule fois — copie-la pour le client. + +**Via SSH** : +```bash +php tools/issue-license.php "ACME Corp" 2027-12-31 1 +``` ## Convention du contenu du ZIP -Le client extrait le ZIP dans `installRoot/Proserve v{version}/`. Donc le ZIP doit contenir **directement** les fichiers `PROSERVE_UE_5_5.exe`, `Engine/`, `PROSERVE_UE_5_5/`, etc. à sa racine (pas de dossier englobant). +Le client extrait le ZIP dans `installRoot/Proserve v{version}/`. Le ZIP peut soit contenir +un dossier racine `Proserve v{version}/`, soit le contenu directement — le launcher détecte +automatiquement le préfixe commun et le strippe. -Test rapide en ligne de commande : -``` -unzip -l proserve-1.4.6.zip | head -10 -``` -Doit lister `PROSERVE_UE_5_5.exe` à la racine, pas `Proserve v1.4.6/PROSERVE_UE_5_5.exe`. +## Test API depuis ton poste -## Test de l'API depuis ton poste - -Une fois uploadé, vérifier : - -``` -curl https://www.tondomaine.com/PS_Launcher/api/health -curl https://www.tondomaine.com/PS_Launcher/api/manifest -curl https://www.tondomaine.com/PS_Launcher/api/releasenotes/1.4.6 +```powershell +curl.exe https://tondomaine.com/PS_Launcher/api/health +curl.exe https://tondomaine.com/PS_Launcher/api/manifest +curl.exe https://tondomaine.com/PS_Launcher/api/releasenotes/1.4.6 ``` -## À mettre à jour avant prod +## Sécurité -- `api/config.php` : tous les `replace_me` / `replace_with_*`. Ce fichier ne doit JAMAIS être commit dans un repo public. -- Dans `manifest/versions.json`, remplace `www.exemple-asterion.com` par ton vrai domaine. +- `api/config.php` est gitignored. Ne jamais le commit. +- Toutes les URLs `/api/*` forcent HTTPS via `.htaccess`. +- Les routes /api/* renvoient toujours du JSON (pas de page Apache 500 HTML). +- L'admin est protégé par session + CSRF + mot de passe bcrypt. +- Les licenses sont stockées via UNIQUE en clair (pour la lookup constant-time côté serveur), + mais ne sont jamais loguées en clair côté audit. +- Côté client : la clé license est chiffrée DPAPI scope CurrentUser dans `%LocalAppData%`. +- Les réponses serveur (manifest, validation license) sont signées Ed25519 — le launcher + embarque la clé publique et refuse toute réponse mal signée. diff --git a/server/admin/.htaccess b/server/admin/.htaccess new file mode 100644 index 0000000..15b3711 --- /dev/null +++ b/server/admin/.htaccess @@ -0,0 +1,12 @@ +# Pas indexé par les moteurs + + Header set X-Robots-Tag "noindex, nofollow, noarchive" + Header set X-Frame-Options "DENY" + Header set X-Content-Type-Options "nosniff" + Header set Referrer-Policy "no-referrer" + + +# Bloquer l'accès direct aux libs + + Require all denied + diff --git a/server/admin/assets/style.css b/server/admin/assets/style.css new file mode 100644 index 0000000..a128709 --- /dev/null +++ b/server/admin/assets/style.css @@ -0,0 +1,132 @@ +:root { + --bg: #1B1B1F; + --bg-card: #26262B; + --bg-elevated: #2C2C32; + --bg-input: #1A1A20; + --border: #37373D; + --text: #F2F2F2; + --text-secondary: #A0A0A8; + --accent: #3B82F6; + --accent-hover: #2563EB; + --installed: #16A34A; + --installed-bg: #0E2A1B; + --busy: #F59E0B; + --busy-bg: #3A2A0E; + --danger: #EF4444; + --danger-bg: #2A1414; +} +* { box-sizing: border-box; } +body { + background: var(--bg); color: var(--text); + font-family: 'Segoe UI', Roboto, system-ui, sans-serif; + font-size: 14px; line-height: 1.5; + margin: 0; +} +nav { + background: #202024; border-bottom: 1px solid var(--border); + padding: 12px 24px; display: flex; gap: 8px; align-items: center; +} +nav .brand { color: #fff; font-weight: 700; margin-right: 16px; } +nav a { + color: var(--text-secondary); text-decoration: none; + padding: 6px 14px; border-radius: 4px; font-size: 13px; +} +nav a:hover { background: var(--bg-elevated); color: var(--text); } +nav a.active { background: var(--bg-elevated); color: var(--text); } +nav .spacer { flex: 1; } +nav a.logout { color: var(--text-secondary); } + +main { padding: 24px; max-width: 1200px; margin: 0 auto; } + +h1 { margin: 0 0 24px 0; font-size: 26px; font-weight: 600; } +h2 { + margin: 0 0 12px 0; font-size: 12px; font-weight: 700; + color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; +} + +.card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: 6px; padding: 20px; margin-bottom: 16px; +} + +.row { display: flex; gap: 16px; flex-wrap: wrap; } +.row .col { flex: 1; min-width: 200px; } + +table { width: 100%; border-collapse: collapse; } +th { + text-align: left; color: var(--text-secondary); font-size: 11px; + text-transform: uppercase; letter-spacing: 0.5px; + padding: 10px 8px; border-bottom: 1px solid var(--border); + font-weight: 600; +} +td { padding: 12px 8px; border-bottom: 1px solid var(--border); vertical-align: middle; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--bg-elevated); } + +.btn { + display: inline-block; padding: 8px 16px; + border-radius: 4px; border: none; cursor: pointer; + font-size: 13px; font-family: inherit; text-decoration: none; + font-weight: 500; +} +.btn-primary { background: var(--accent); color: white; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-success { background: var(--installed); color: white; } +.btn-danger { background: var(--danger); color: white; } +.btn-secondary { background: var(--bg-elevated); color: var(--text); } +.btn-secondary:hover { background: #3A3A40; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +input[type=text], input[type=password], input[type=date], input[type=datetime-local], +input[type=number], select, textarea { + background: var(--bg-input); color: var(--text); + border: 1px solid var(--border); padding: 8px 10px; + border-radius: 4px; font-family: inherit; font-size: 13px; + width: 100%; +} +input:focus, textarea:focus, select:focus { + outline: none; border-color: var(--accent); +} +label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-secondary); font-weight: 500; } +.field { margin-bottom: 16px; } +.field input[type=checkbox] { width: auto; margin-right: 8px; } + +.flash { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; } +.flash.error { background: var(--danger-bg); color: #FCA5A5; border: 1px solid var(--danger); } +.flash.success { background: var(--installed-bg); color: #6EE7B7; border: 1px solid var(--installed); } + +.badge { + display: inline-block; padding: 2px 8px; border-radius: 10px; + font-size: 11px; font-weight: 600; color: white; line-height: 1.5; +} +.badge-success { background: var(--installed); } +.badge-danger { background: var(--danger); } +.badge-warning { background: var(--busy); } +.badge-secondary { background: var(--bg-elevated); color: var(--text-secondary); } + +.muted { color: var(--text-secondary); } + +.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } + +code { + background: var(--bg-input); padding: 2px 6px; border-radius: 3px; + font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 12px; + color: #C5E1A5; +} +pre { + background: var(--bg-input); padding: 12px; border-radius: 4px; + font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 12px; + overflow: auto; white-space: pre-wrap; word-break: break-all; +} + +.kpi { font-size: 36px; font-weight: 700; line-height: 1; } +.kpi-label { font-size: 11px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; font-weight: 600; } + +ol, ul { margin: 0; padding-left: 20px; } +li { margin-bottom: 4px; } + +details summary { cursor: pointer; padding: 4px 0; } +details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px; margin: 4px 0; } + +a { color: var(--accent); } +a:hover { text-decoration: underline; } diff --git a/server/admin/audit.php b/server/admin/audit.php new file mode 100644 index 0000000..f539c00 --- /dev/null +++ b/server/admin/audit.php @@ -0,0 +1,121 @@ +prepare("SELECT COUNT(*) FROM audit_log $where")->execute($params) ?? 0; +$totalStmt = $db->prepare("SELECT COUNT(*) FROM audit_log $where"); +$totalStmt->execute($params); +$total = (int)$totalStmt->fetchColumn(); +$totalPages = max(1, (int)ceil($total / $pageSize)); + +$listStmt = $db->prepare("SELECT a.*, l.owner_name FROM audit_log a LEFT JOIN licenses l ON l.id = a.license_id $where ORDER BY a.id DESC LIMIT $pageSize OFFSET $offset"); +$listStmt->execute($params); +$rows = $listStmt->fetchAll(); + +$eventTypes = $db->query('SELECT DISTINCT event FROM audit_log ORDER BY event')->fetchAll(\PDO::FETCH_COLUMN); + +Layout::header('Audit', 'audit'); +?> +

Audit log

+ +
+
+ + + + Réinitialiser + + page / entrée(s) +
+ + +

Aucune entrée d'audit.

+ + + + + + + + + + + + + 'badge-success', + 'validate_invalid', 'validate_revoked', 'machine_limit' => 'badge-danger', + 'validate_expired' => 'badge-warning', + default => 'badge-secondary', + }; + $detail = $r['detail'] ? json_decode($r['detail'], true) : null; + ?> + + + + + + + + + +
DateÉvénementLicenseIPDétail
+ + # + + () + + + + + + + + +
+ +
+
+ 1): ?> + ← Précédent + +
+
+ + Suivant → + +
+
+ +
+ (int)$db->query('SELECT COUNT(*) FROM licenses')->fetchColumn(), + 'licenses_active' => (int)$db->query('SELECT COUNT(*) FROM licenses WHERE revoked_at IS NULL AND download_entitlement_until >= NOW()')->fetchColumn(), + 'licenses_expired' => (int)$db->query('SELECT COUNT(*) FROM licenses WHERE revoked_at IS NULL AND download_entitlement_until < NOW()')->fetchColumn(), + 'licenses_revoked' => (int)$db->query('SELECT COUNT(*) FROM licenses WHERE revoked_at IS NOT NULL')->fetchColumn(), + 'machines_30d' => (int)$db->query('SELECT COUNT(*) FROM license_machines WHERE last_seen >= DATE_SUB(NOW(), INTERVAL 30 DAY)')->fetchColumn(), + 'audit_24h' => (int)$db->query('SELECT COUNT(*) FROM audit_log WHERE ts >= DATE_SUB(NOW(), INTERVAL 24 HOUR)')->fetchColumn(), +]; + +$manifestPath = dirname(__DIR__) . '/manifest/versions.json'; +$manifest = is_file($manifestPath) ? json_decode(file_get_contents($manifestPath), true) : null; +$buildsDir = dirname(__DIR__) . '/builds'; +$zips = is_dir($buildsDir) ? glob("$buildsDir/*.zip") : []; + +$lastAudit = $db->query('SELECT * FROM audit_log ORDER BY id DESC LIMIT 10')->fetchAll(); + +Layout::header('Dashboard', 'dashboard'); +?> +

Dashboard

+ +
+
+
Licenses actives
+
+
+
+
Expirées
+
+
+
+
Révoquées
+
+
+
+
Machines vues (30j)
+
+
+
+
Validations (24h)
+
+
+
+ +

État du dépôt de versions

+
+

+ entrée(s) dans le manifest • + ZIP(s) dans builds/ • + signature manifest : + NON SIGNÉ" + : "SIGNÉ Ed25519" ?> +

+ +

Dernière version annoncée : v + (publiée le )

+ + Gérer les versions → +
+ +

Activité récente

+
+ +

Aucune activité enregistrée.

+ + + + + 'badge-success', + 'validate_invalid', 'validate_revoked', 'machine_limit' => 'badge-danger', + 'validate_expired' => 'badge-warning', + default => 'badge-secondary', + }; + ?> + + + + + + + + +
DateÉvénementLicenseIP
—' ?>
+ Voir tout → + +
+ 0, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_name('PSLAUNCHER_ADMIN'); + session_start(); + } + if (!isset($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + + public static function isLoggedIn(): bool + { + return !empty($_SESSION['admin']); + } + + public static function login(string $username, string $password, array $config): bool + { + $hash = $config['admin_password_hash'] ?? ''; + if ($hash === '') return false; + if (!password_verify($password, $hash)) return false; + + $_SESSION['admin'] = ['user' => $username, 'at' => time()]; + // régénère l'ID après login pour éviter session fixation + session_regenerate_id(true); + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + return true; + } + + public static function logout(): void + { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']); + } + session_destroy(); + } + + public static function requireLogin(): void + { + self::start(); + if (!self::isLoggedIn()) { + header('Location: login.php'); + exit; + } + } + + public static function csrfToken(): string + { + return $_SESSION['csrf'] ?? ''; + } + + public static function checkCsrf(): void + { + $token = $_POST['csrf'] ?? ''; + if (!hash_equals($_SESSION['csrf'] ?? '', $token)) { + http_response_code(403); + die('CSRF token invalid'); + } + } +} diff --git a/server/admin/lib/Layout.php b/server/admin/lib/Layout.php new file mode 100644 index 0000000..6ae81c9 --- /dev/null +++ b/server/admin/lib/Layout.php @@ -0,0 +1,84 @@ + + + + + + +{$titleEsc} — PS_Launcher Admin + + + +{$nav} +
+HTML; + } + + public static function footer(): void + { + echo "
"; + } + + private static function navHtml(string $active): string + { + $items = [ + 'dashboard' => ['index.php', 'Dashboard'], + 'licenses' => ['licenses.php', 'Licenses'], + 'versions' => ['versions.php', 'Versions'], + 'audit' => ['audit.php', 'Audit'], + ]; + $links = ''; + foreach ($items as $k => [$href, $label]) { + $cls = ($k === $active) ? 'active' : ''; + $links .= sprintf( + '%s', + htmlspecialchars($href), + $cls, + htmlspecialchars($label) + ); + } + return << + PS_Launcher Admin + {$links} + + Déconnexion + +HTML; + } + + public static function flash(?string $msg, string $type = 'success'): void + { + if ($msg === null || $msg === '') return; + $cls = $type === 'error' ? 'error' : 'success'; + echo "
" . nl2br(htmlspecialchars($msg)) . "
"; + } + + public static function csrfField(): string + { + return ''; + } + + public static function formatBytes(int $bytes): string + { + if ($bytes <= 0) return '—'; + $units = ['o', 'Ko', 'Mo', 'Go', 'To']; + $u = 0; + $v = (float)$bytes; + while ($v >= 1024 && $u < count($units) - 1) { $v /= 1024; $u++; } + return number_format($v, 1) . ' ' . $units[$u]; + } +} diff --git a/server/admin/licenses.php b/server/admin/licenses.php new file mode 100644 index 0000000..8a586cc --- /dev/null +++ b/server/admin/licenses.php @@ -0,0 +1,213 @@ +prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)') + ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]); + $message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée."; + break; + } catch (PDOException $e) { + if (str_contains($e->getMessage(), 'Duplicate')) { continue; } + throw $e; + } + } + } + elseif ($action === 'revoke') { + $id = (int)($_POST['id'] ?? 0); + $db->prepare('UPDATE licenses SET revoked_at = NOW() WHERE id = ?')->execute([$id]); + $message = "License #{$id} révoquée."; + } + elseif ($action === 'unrevoke') { + $id = (int)($_POST['id'] ?? 0); + $db->prepare('UPDATE licenses SET revoked_at = NULL WHERE id = ?')->execute([$id]); + $message = "License #{$id} réactivée."; + } + elseif ($action === 'extend') { + $id = (int)($_POST['id'] ?? 0); + $newUntil = trim($_POST['new_until'] ?? ''); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newUntil)) { + throw new Exception('Date invalide.'); + } + $db->prepare('UPDATE licenses SET download_entitlement_until = ? WHERE id = ?') + ->execute([$newUntil . ' 23:59:59', $id]); + $message = "License #{$id} prolongée jusqu'au {$newUntil}."; + } + elseif ($action === 'reset_machines') { + $id = (int)($_POST['id'] ?? 0); + $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); + $message = "Machines libérées pour la license #{$id}."; + } + } catch (Exception $e) { + $message = $e->getMessage(); + $messageType = 'error'; + } +} + +$licenses = $db->query( + 'SELECT l.*, + (SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count + FROM licenses l + ORDER BY l.id DESC' +)->fetchAll(); + +Layout::header('Licenses', 'licenses'); +?> +

Licenses

+ + + + +
+

⚡ Nouvelle clé — copie-la dès maintenant

+
+

Elle est stockée hashée — la prochaine fois que tu rechargeras cette page elle ne sera plus visible.

+
+ + +
+

Émettre une license

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDClientÉmiseExpireMachinesStatutActions
# + + +
+ +
/ +
+ Prolonger +
+ + + + + +
+
+ 0): ?> +
+ + + + +
+ + +
+ + + + +
+ +
+ + + + +
+ +
Aucune license émise.
+
+ +
+
+

PS_Launcher

+

Backoffice administration

+ +
+ +
+ + +
+
+ + +
+ +
+
+
+ 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []]; + return json_decode(file_get_contents($path), true) ?? []; +} + +function saveManifest(string $path, array $manifest): void +{ + // Re-tri par SemVer descendant + usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version'])); + file_put_contents( + $path, + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + Auth::checkCsrf(); + $action = $_POST['action'] ?? ''; + $manifest = loadManifest($manifestPath); + + try { + if ($action === 'add') { + $version = trim($_POST['version'] ?? ''); + $minLicDate = trim($_POST['min_license_date'] ?? ''); + $releasedAt = trim($_POST['released_at'] ?? ''); + $notes = $_POST['notes'] ?? ''; + $available = isset($_POST['available']); + + if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) { + throw new Exception('Numéro de version invalide (format X.Y.Z attendu).'); + } + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { + throw new Exception('Date min_license_date invalide.'); + } + foreach ($manifest['versions'] as $v) { + if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest."); + } + + $releasedAtIso = $releasedAt !== '' + ? (new DateTime($releasedAt, 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'; + $manifest['versions'][] = [ + 'version' => $version, + 'releasedAt' => $releasedAtIso, + 'executable' => 'PROSERVE_UE_5_5.exe', + 'installFolderTemplate' => 'Proserve v{version}', + 'download' => [ + 'url' => "{$base}/builds/proserve-{$version}.zip", + 'sizeBytes' => 0, + 'sha256' => 'REPLACE_AFTER_BUILD', + ], + 'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}", + 'minLicenseDate' => $minLicDate, + 'availableForDownload' => $available, + ]; + + saveManifest($manifestPath, $manifest); + + if ($notes !== '') { + 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) »."; + } + elseif ($action === 'edit_notes') { + $version = $_POST['version'] ?? ''; + $notes = $_POST['notes'] ?? ''; + if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.'); + if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); + file_put_contents("$notesDir/{$version}.md", $notes); + $message = "Release notes de v{$version} mises à jour."; + } + elseif ($action === 'edit_meta') { + $version = $_POST['version'] ?? ''; + $minLicDate = trim($_POST['min_license_date'] ?? ''); + $releasedAt = trim($_POST['released_at'] ?? ''); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { + throw new Exception('min_license_date invalide.'); + } + foreach ($manifest['versions'] as &$v) { + if ($v['version'] === $version) { + $v['minLicenseDate'] = $minLicDate; + if ($releasedAt !== '') { + $v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); + } + break; + } + } + unset($v); + saveManifest($manifestPath, $manifest); + $message = "Méta de v{$version} mises à jour."; + } + elseif ($action === 'toggle_available') { + $version = $_POST['version'] ?? ''; + foreach ($manifest['versions'] as &$v) { + if ($v['version'] === $version) { + $v['availableForDownload'] = !($v['availableForDownload'] ?? true); + break; + } + } + unset($v); + saveManifest($manifestPath, $manifest); + $message = "Disponibilité de v{$version} mise à jour."; + } + elseif ($action === 'delete') { + $version = $_POST['version'] ?? ''; + $manifest['versions'] = array_values(array_filter( + $manifest['versions'], + fn($v) => $v['version'] !== $version + )); + saveManifest($manifestPath, $manifest); + $message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu)."; + } + elseif ($action === 'sync') { + $output = []; + $exitCode = 0; + $cwd = escapeshellarg($root); + exec("cd $cwd && php tools/sign-manifest.php 2>&1", $output, $exitCode); + $message = "Sortie du script :\n" . implode("\n", $output); + if ($exitCode !== 0) $messageType = 'error'; + } + } catch (Exception $e) { + $message = $e->getMessage(); + $messageType = 'error'; + } +} + +$manifest = loadManifest($manifestPath); +$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : []; +$zipSizes = []; +foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z"); + +// Pour l'édition : pré-charge les release notes existantes +$existingNotes = []; +foreach ($manifest['versions'] ?? [] as $v) { + $f = "$notesDir/{$v['version']}.md"; + $existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : ''; +} + +Layout::header('Versions', 'versions'); +?> +

Versions

+ + + +
+

Workflow d'une nouvelle release

+
    +
  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).
  2. +
  3. Upload le ZIP correspondant via SFTP dans www/PS_Launcher/builds/ en respectant le nom proserve-{version}.zip.
  4. +
  5. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  6. +
  7. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».
  8. +
+
+ +
+

Ajouter une version

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+
+ +
+
+

Manifest actuel

+ + Signature : + NON SIGNÉ" + : "SIGNÉ Ed25519" ?> + + • latest : v + + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VersionReleasemin_licenseZIPHashVisibleActions
v + + présent + + + absent +
attendu :
+ +
+ + + + à calculer + + +
+ + + + +
+
+
+ Méta +
+ + + +
+ + +
+
+ + +
+ +
+
+
+ Notes +
+ + + + +

+ +
+
+
+ + + + +
+
Aucune version dans le manifest.
+
+ +
+

ZIPs présents dans builds/

+ +

Aucun ZIP. Upload tes fichiers via SFTP dans www/PS_Launcher/builds/, puis clique « Sync » ci-dessus.

+ + + + + + + + + + + + +
FichierTailleRéférencé ?
oui" + : "orphelin" ?>
+ +
+ 10, + + // === BACKOFFICE ADMIN === + // Mot de passe bcrypt pour la connexion à /PS_Launcher/admin/ + // Génère le hash via SSH OVH : + // php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);" + // puis colle le résultat ci-dessous. + 'admin_password_hash' => '', ];