v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign : - Pages versions.php + licenses.php : remplace les <details> inline qui débordaient horizontalement par UN bouton « ✎ Modifier » par row qui ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes, BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel, BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets. - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la clé aux clients. - Overlay « hashing en cours » plein écran sur tous les boutons de hash (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé par scope (bulk vs single). - Date de release passée de datetime-local à date (l'heure n'a pas de sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an pour min_license_date (= license standard couvre les releases sur 1 an). Emails de notification release : - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV) - Onglet « Contacts » sur la modal licenses pour saisir les emails par license (parsing tolérant : CSV, ligne par ligne, point-virgule) - Bouton « ✉ Notifier » par version : POST notify_release filtre les licenses éligibles (channel match + min_license_date + can_see_betas pour BÊTA) et envoie un email HTML à chaque contact (dédup global) - Template email table-based + bgcolor (compat Outlook/Word engine), navy foncé #0F172A, logo Asterion en CID embed (= affichage direct sans demande de permission Outlook), bouton download installer centré (align="center" + margin auto), release notes en <pre> - Mailer.php helper : parse emails, multipart/related avec attachments inline, fallback execCommand pour clipboard 4-digit version support (X.Y.Z.B) : - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent = 0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve le format d'origine au round-trip. - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php, Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit - Use case : dev/test iterations cohabitant avec leur release stable Bugs fixes : - migrate.php : strip ligne par ligne les commentaires SQL avant le check is-empty. Sans ça, le PREMIER chunk d'un fichier migration (= header + premier ALTER) commençait par `--` et était silencieusement skip → ALTER jamais appliqué. Affectait migrations 003, 004. - SignManifest::getOrComputeSha256 ignorait son cache interne quand force demandé par le caller, retournant l'ancien hash en 0 ms même après re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force. Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique. - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh triggers, avec HEAD probe pour comparer taille serveur vs manifest → message d'erreur explicite si ZIP tronqué. Bps display lissé sur une fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un segment finit / Polly retry. SHA mismatch popup enrichi avec les deux SHAs (attendu vs calculé) extraits via regex de l'exception. - DownloadUrl.php : signature de l'URL utilisait un template hardcodé /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit maintenant download.url du manifest et signe le filename réel. Strings i18n (5 langues) : - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch, stale manifest, manifest refreshed auto-retry, force fresh menu Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
server/admin/assets/asterion-logo.png
Normal file
BIN
server/admin/assets/asterion-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
|
||||
|
||||
a { color: var(--accent); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ============================================================
|
||||
Modal (<dialog>) — éditeur d'une entrée du manifest
|
||||
Remplace les <details> inline qui débordaient horizontalement
|
||||
============================================================ */
|
||||
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
|
||||
showModal() automatiquement). Sans le sélecteur [open], les règles
|
||||
`display: flex` etc. écrasaient le `display: none` du user-agent
|
||||
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
|
||||
cachés tant que pas explicitement ouverts. */
|
||||
dialog.edit-modal[open] {
|
||||
background: var(--bg-card); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 0; margin: auto;
|
||||
width: min(640px, 92vw); max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
dialog.edit-modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
dialog.edit-modal > header {
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
dialog.edit-modal > header h3 {
|
||||
margin: 0; font-size: 15px; font-weight: 600;
|
||||
flex: 1; color: var(--text);
|
||||
}
|
||||
dialog.edit-modal > header .close-x {
|
||||
background: transparent; border: none;
|
||||
color: var(--text-secondary); cursor: pointer;
|
||||
font-size: 20px; line-height: 1; padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
dialog.edit-modal > header .close-x:hover {
|
||||
background: var(--bg-card); color: var(--text);
|
||||
}
|
||||
|
||||
/* Onglets verticaux compacts en tête */
|
||||
.modal-tabs {
|
||||
display: flex; gap: 4px; padding: 8px 12px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modal-tabs button {
|
||||
background: transparent; border: none;
|
||||
color: var(--text-secondary); cursor: pointer;
|
||||
font-size: 13px; font-weight: 500;
|
||||
padding: 8px 14px; border-radius: 4px 4px 0 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.modal-tabs button:hover {
|
||||
color: var(--text); background: var(--bg-elevated);
|
||||
}
|
||||
.modal-tabs button.active {
|
||||
color: var(--text); border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-body > section[hidden] { display: none; }
|
||||
.modal-body > section { display: block; }
|
||||
.modal-body .field:last-child { margin-bottom: 0; }
|
||||
.modal-body .hint {
|
||||
color: var(--text-secondary); font-size: 11px;
|
||||
margin: 4px 0 0 0; line-height: 1.4;
|
||||
}
|
||||
.modal-body .warn {
|
||||
color: #FBBF24; font-size: 11px;
|
||||
margin: 4px 0 0 0; line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
/* Tooltip-style note in the modal sections explaining a section purpose */
|
||||
.section-intro {
|
||||
background: var(--bg-input); border-left: 3px solid var(--accent);
|
||||
padding: 10px 14px; border-radius: 4px;
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
margin-bottom: 16px; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Liste de checkboxes verticale propre (pour Channels) */
|
||||
.checkbox-list {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.checkbox-list label {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 4px;
|
||||
font-size: 13px; color: var(--text); font-weight: normal;
|
||||
cursor: pointer; margin-bottom: 0;
|
||||
}
|
||||
.checkbox-list label:hover { background: var(--bg-elevated); }
|
||||
.checkbox-list label code { flex: 0 0 auto; }
|
||||
.checkbox-list label .muted { margin-left: auto; }
|
||||
|
||||
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
|
||||
.row-actions {
|
||||
display: inline-flex; gap: 6px; align-items: center;
|
||||
flex-wrap: nowrap; justify-content: flex-end;
|
||||
}
|
||||
.row-actions .btn { white-space: nowrap; }
|
||||
|
||||
/* ============================================================
|
||||
Overlay blocant affiché pendant les opérations longues
|
||||
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
|
||||
Visible tant que le browser n'a pas reçu la réponse POST,
|
||||
disparaît naturellement au reload de page suivant.
|
||||
============================================================ */
|
||||
.busy-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.busy-overlay[hidden] { display: none; }
|
||||
.busy-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 40px 56px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.busy-content h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.busy-content p {
|
||||
margin: 6px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.busy-spinner {
|
||||
width: 56px; height: 56px;
|
||||
border: 4px solid var(--bg-elevated);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: busy-spin 1s linear infinite;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
@keyframes busy-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($action === 'set_launcher') {
|
||||
$lver = trim($_POST['launcher_version'] ?? '');
|
||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
||||
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
|
||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
|
||||
}
|
||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
|
||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||
}
|
||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||
@@ -145,14 +146,14 @@ Layout::header('Launcher', 'launcher');
|
||||
<input type="hidden" name="action" value="set_launcher">
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Version launcher (X.Y.Z)</label>
|
||||
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
|
||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label>Version minimale requise (X.Y.Z)</label>
|
||||
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
|
||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">Définir</button>
|
||||
|
||||
201
server/admin/lib/Mailer.php
Normal file
201
server/admin/lib/Mailer.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PSLauncher\Admin;
|
||||
|
||||
/**
|
||||
* Petit helper d'envoi d'emails de notification (release announce).
|
||||
*
|
||||
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
|
||||
* passe par le relais SMTP local et marche out-of-the-box sans config
|
||||
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
|
||||
* — on a besoin de notifications opportunistes, pas de delivery garantie.
|
||||
*
|
||||
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
|
||||
* même : on remplace juste <see cref="sendInternal"/> par un appel
|
||||
* <code>$mailer->send()</code>.
|
||||
*/
|
||||
final class Mailer
|
||||
{
|
||||
/**
|
||||
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
|
||||
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
|
||||
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
|
||||
* popup pour un copy/paste avec une virgule en trop).
|
||||
*
|
||||
* @return list<string> emails uniques lowercase
|
||||
*/
|
||||
public static function parseEmails(?string $raw): array
|
||||
{
|
||||
if ($raw === null || trim($raw) === '') return [];
|
||||
// Split sur tout séparateur courant : virgule, point-virgule, espace,
|
||||
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
|
||||
// Excel, ou ligne par ligne — peu importe.
|
||||
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$out = [];
|
||||
foreach ($tokens as $t) {
|
||||
$t = strtolower(trim($t));
|
||||
if ($t === '') continue;
|
||||
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
|
||||
if (!in_array($t, $out, true)) $out[] = $t;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse : prend un tableau d'emails et le formate pour stockage DB
|
||||
* (séparé par virgules). NULL si la liste est vide pour économiser
|
||||
* une ligne en DB sur les licenses sans contacts.
|
||||
*/
|
||||
public static function joinForStorage(array $emails): ?string
|
||||
{
|
||||
$emails = array_values(array_filter(
|
||||
array_map('trim', $emails),
|
||||
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
|
||||
));
|
||||
return empty($emails) ? null : implode(', ', $emails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
|
||||
* à un destinataire. Best-effort : retourne true en cas de succès apparent
|
||||
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
|
||||
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
|
||||
* résultat brut.
|
||||
*
|
||||
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
|
||||
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
|
||||
* Les images sont attachées comme pièces jointes inline, référencées dans
|
||||
* le HTML via <c><img src="cid:..."></c>. Avantage vs hotlink HTTP :
|
||||
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
|
||||
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
|
||||
*
|
||||
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
|
||||
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
|
||||
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
|
||||
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
|
||||
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
|
||||
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
|
||||
*/
|
||||
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
|
||||
{
|
||||
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
|
||||
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
|
||||
$replyTo = $notifConfig['reply_to'] ?? '';
|
||||
|
||||
// Filtre les inline images : ne garde que celles dont le fichier existe
|
||||
// ET est lisible. Si une image manque, on log silencieusement et on
|
||||
// continue avec les autres — l'email part de toute façon.
|
||||
$validImages = [];
|
||||
foreach ($inlineImages as $img) {
|
||||
$p = $img['path'] ?? '';
|
||||
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
|
||||
$validImages[] = $img;
|
||||
}
|
||||
|
||||
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
|
||||
// multipart/related contenant multipart/alternative + image(s).
|
||||
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
|
||||
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
|
||||
|
||||
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
|
||||
// pour avoir des "Per-user emails" et faciliter le debugging.
|
||||
$contentType = empty($validImages)
|
||||
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
|
||||
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
|
||||
$headers = [
|
||||
'MIME-Version: 1.0',
|
||||
"Content-Type: {$contentType}",
|
||||
'From: ' . self::formatAddress($fromName, $from),
|
||||
'X-Mailer: PSLauncher-Admin',
|
||||
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
|
||||
];
|
||||
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||
$headers[] = 'Reply-To: ' . $replyTo;
|
||||
}
|
||||
|
||||
// === Construction du body ===
|
||||
$bodyText = self::htmlToPlainText($bodyHtml);
|
||||
|
||||
// Bloc multipart/alternative (text + html)
|
||||
$altPart = "--{$boundaryAlt}\r\n";
|
||||
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||
$altPart .= $bodyText . "\r\n\r\n";
|
||||
$altPart .= "--{$boundaryAlt}\r\n";
|
||||
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||
$altPart .= $bodyHtml . "\r\n\r\n";
|
||||
$altPart .= "--{$boundaryAlt}--\r\n";
|
||||
|
||||
if (empty($validImages)) {
|
||||
// Pas d'images → email simple, juste l'alternative
|
||||
$body = $altPart;
|
||||
} else {
|
||||
// Avec images → multipart/related wrappant l'alt + chaque image
|
||||
$body = "--{$boundaryRel}\r\n";
|
||||
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
|
||||
$body .= $altPart;
|
||||
foreach ($validImages as $img) {
|
||||
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
|
||||
$type = $img['type'] ?? 'application/octet-stream';
|
||||
$path = $img['path'];
|
||||
$name = basename($path);
|
||||
$data = @file_get_contents($path);
|
||||
if ($data === false) continue;
|
||||
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
|
||||
$body .= "\r\n--{$boundaryRel}\r\n";
|
||||
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
|
||||
$body .= "Content-Transfer-Encoding: base64\r\n";
|
||||
$body .= "Content-ID: <{$cid}>\r\n";
|
||||
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
|
||||
$body .= $b64;
|
||||
}
|
||||
$body .= "\r\n--{$boundaryRel}--\r\n";
|
||||
}
|
||||
|
||||
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
|
||||
try {
|
||||
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un HTML simple en plain text pour la partie text/plain du
|
||||
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
|
||||
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
|
||||
*/
|
||||
private static function htmlToPlainText(string $html): string
|
||||
{
|
||||
// Convert <br> et <p> en sauts de ligne avant strip_tags
|
||||
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
|
||||
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
|
||||
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
|
||||
$s = strip_tags($s);
|
||||
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return trim($s);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
|
||||
* Échappe les chars spéciaux conformément à RFC 5322.
|
||||
*/
|
||||
private static function formatAddress(string $name, string $email): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') return $email;
|
||||
// Encode le name en MIME si pas pur ASCII
|
||||
if (preg_match('/[\x80-\xFF]/', $name)) {
|
||||
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
|
||||
} else {
|
||||
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
|
||||
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
|
||||
$name = '"' . str_replace('"', '\\"', $name) . '"';
|
||||
}
|
||||
}
|
||||
return "{$name} <{$email}>";
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/Layout.php';
|
||||
require __DIR__ . '/lib/Channels.php';
|
||||
require __DIR__ . '/lib/Mailer.php';
|
||||
require __DIR__ . '/../api/lib/Db.php';
|
||||
|
||||
use PSLauncher\Admin\Auth;
|
||||
use PSLauncher\Admin\Layout;
|
||||
use PSLauncher\Admin\Channels;
|
||||
use PSLauncher\Admin\Mailer;
|
||||
use PSLauncher\Db;
|
||||
|
||||
Auth::requireLogin();
|
||||
@@ -117,13 +119,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
||||
}
|
||||
elseif ($action === 'toggle_betas') {
|
||||
// Renommé semantically (toggle → set explicit) sans casser le nom
|
||||
// de l'action POST pour préserver les éventuels liens externes
|
||||
// existants. La modal envoie maintenant `enabled=1` si checkbox
|
||||
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
|
||||
$row->execute([$id]);
|
||||
$now = (int)$row->fetchColumn();
|
||||
$message = $now
|
||||
$enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
|
||||
$message = $enabled
|
||||
? "License #{$id} : accès aux versions BÊTA activé."
|
||||
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
||||
}
|
||||
@@ -165,6 +168,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
|
||||
}
|
||||
}
|
||||
elseif ($action === 'set_contact_emails') {
|
||||
// Met à jour la liste des emails de contact pour les notifications
|
||||
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
|
||||
// virgules, point-virgules — peu importe), on parse + valide via
|
||||
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
|
||||
// valide → on stocke NULL (= pas de notif possible pour cette
|
||||
// license, juste skip silencieux à l'envoi).
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$raw = (string)($_POST['contact_emails'] ?? '');
|
||||
if ($id <= 0) {
|
||||
throw new Exception('License id invalide.');
|
||||
}
|
||||
$parsed = Mailer::parseEmails($raw);
|
||||
$stored = Mailer::joinForStorage($parsed);
|
||||
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
|
||||
->execute([$stored, $id]);
|
||||
$count = count($parsed);
|
||||
$message = $count === 0
|
||||
? "License #{$id} : contacts de notification supprimés."
|
||||
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
|
||||
}
|
||||
elseif ($action === 'reset_machines') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
||||
@@ -295,8 +319,21 @@ Layout::header('Licenses', 'licenses');
|
||||
<td>#<?= $l['id'] ?></td>
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
||||
<?php // Clé license affichée en monospace + bouton 1-clic
|
||||
// pour copier dans le presse-papier. Permet à l'admin
|
||||
// de la transmettre rapidement à un client sans passer
|
||||
// par phpMyAdmin. Sensibilité OK : la page admin est
|
||||
// déjà derrière auth + la clé seule ne suffit pas
|
||||
// (validation serveur HMAC + machine_id requis). ?>
|
||||
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
|
||||
<button type="button" class="copy-btn"
|
||||
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
|
||||
title="Copier la clé license"
|
||||
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
|
||||
</div>
|
||||
<?php if (!empty($l['notes'])): ?>
|
||||
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
|
||||
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
||||
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses');
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
|
||||
<div style="margin-bottom: 6px;">
|
||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||
title="Channel actuellement attribué à cette license">
|
||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||
</code>
|
||||
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
|
||||
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
|
||||
pour trouver l'option. -->
|
||||
<form method="post" style="display: inline-block; margin-right: 4px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="toggle_betas">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
|
||||
type="submit" style="font-size: 11px; padding: 4px 10px;"
|
||||
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
|
||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Channel : modifier via un menu compact -->
|
||||
<details style="display: inline-block;">
|
||||
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
|
||||
✎ Channel
|
||||
</summary>
|
||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channel">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<select name="channel" style="min-width: 160px;">
|
||||
<?php foreach ($availableChannels as $value => $label):
|
||||
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">OK</button>
|
||||
</form>
|
||||
</details>
|
||||
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
|
||||
les éditeurs sont passés dans le modal pour éviter
|
||||
l'encombrement de la cellule. -->
|
||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||
title="Channel actuellement attribué">
|
||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||
</code>
|
||||
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
||||
<?php endif; ?>
|
||||
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
||||
<?php if ($hasLock): ?>
|
||||
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||
<td style="text-align:right; white-space: nowrap;">
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Prolonger</summary>
|
||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
||||
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||
✎ Modifier
|
||||
</button>
|
||||
<?php if (!$isRevoked): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:inline">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="unrevoke">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
// Modal d'édition (capturé en buffer, flush après </table>).
|
||||
// Même pattern que versions.php : <dialog> dans <table> est
|
||||
// foster-parented imprévisiblement → on émet hors de la table.
|
||||
ob_start();
|
||||
?>
|
||||
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||
<header>
|
||||
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
|
||||
<button type="button" class="close-x" aria-label="Fermer"
|
||||
onclick="this.closest('dialog').close()">×</button>
|
||||
</header>
|
||||
|
||||
<nav class="modal-tabs">
|
||||
<button type="button" data-tab="prolong" class="active">Prolonger</button>
|
||||
<button type="button" data-tab="slots">Slots</button>
|
||||
<button type="button" data-tab="channel">Channel</button>
|
||||
<button type="button" data-tab="beta">BÊTA</button>
|
||||
<button type="button" data-tab="lock">Lock</button>
|
||||
<button type="button" data-tab="contacts">Contacts</button>
|
||||
<button type="button" data-tab="machines">Machines</button>
|
||||
</nav>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- TAB : Prolonger -->
|
||||
<section data-tab="prolong">
|
||||
<p class="section-intro">
|
||||
Repousse la date d'expiration de l'entitlement.
|
||||
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="extend">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<input type="date" name="new_until" required style="width: 150px;">
|
||||
<button class="btn btn-primary" type="submit">OK</button>
|
||||
<div class="field">
|
||||
<label>Nouvelle date d'expiration</label>
|
||||
<input type="date" name="new_until" required>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Prolonger</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Slots</summary>
|
||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
||||
</section>
|
||||
|
||||
<!-- TAB : Slots -->
|
||||
<section data-tab="slots" hidden>
|
||||
<p class="section-intro">
|
||||
Nombre maximum de machines pouvant être activées simultanément.
|
||||
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
|
||||
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||
Pour réduire en-dessous du nombre actif, libère d'abord les slots
|
||||
via l'onglet « Machines ».
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_max_machines">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<input type="number" name="max_machines" min="1" max="100"
|
||||
value="<?= (int)$l['max_machines'] ?>" required
|
||||
style="width: 70px;"
|
||||
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
|
||||
<button class="btn btn-primary" type="submit">OK</button>
|
||||
<div class="field">
|
||||
<label>Slots max</label>
|
||||
<input type="number" name="max_machines" min="1" max="100"
|
||||
value="<?= (int)$l['max_machines'] ?>" required>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary"
|
||||
title="Mot de passe qui verrouille la section Settings → Avancés du launcher pour cette license. Laisser vide pour retirer le verrouillage.">
|
||||
<?= $hasLock ? '🔒 Lock' : 'Lock' ?>
|
||||
</summary>
|
||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
||||
</section>
|
||||
|
||||
<!-- TAB : Channel -->
|
||||
<section data-tab="channel" hidden>
|
||||
<p class="section-intro">
|
||||
Quel manifest sert cette license ? « default » = manifest public.
|
||||
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
|
||||
permettent de servir des versions ciblées (police, firefighter, …).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channel">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<div class="field">
|
||||
<label>Channel</label>
|
||||
<select name="channel">
|
||||
<?php foreach ($availableChannels as $value => $label):
|
||||
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : BÊTA -->
|
||||
<section data-tab="beta" hidden>
|
||||
<p class="section-intro">
|
||||
Si activé, le client voit en plus des versions stables toutes les
|
||||
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
|
||||
elles lui sont invisibles (filtrées côté manifest signé).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="toggle_betas">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="checkbox" name="enabled" value="1"
|
||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
|
||||
Voir les versions BÊTA
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Lock (settings password) -->
|
||||
<section data-tab="lock" hidden>
|
||||
<p class="section-intro">
|
||||
Mot de passe qui verrouille la section Settings → Avancés du
|
||||
launcher pour cette license. Vide = retirer le verrouillage.
|
||||
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_settings_lock_password">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<input type="password" name="settings_lock_password"
|
||||
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe (≥4)' ?>"
|
||||
minlength="0" maxlength="100"
|
||||
style="width: 200px;"
|
||||
autocomplete="new-password">
|
||||
<button class="btn btn-primary" type="submit">OK</button>
|
||||
<div class="field">
|
||||
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
|
||||
<input type="password" name="settings_lock_password"
|
||||
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
|
||||
minlength="0" maxlength="100"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<?php if ($l['machines_count'] > 0): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="reset_machines">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if (!$isRevoked): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Révoquer</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:inline">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="unrevoke">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit">Réactiver</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Contacts (emails de notification release) -->
|
||||
<section data-tab="contacts" hidden>
|
||||
<p class="section-intro">
|
||||
Liste d'adresses email à notifier quand tu publies une nouvelle
|
||||
version de PROSERVE accessible à cette license (matching channel +
|
||||
entitlement). Format libre : une adresse par ligne, virgules, ou
|
||||
point-virgules. Adresses invalides ignorées silencieusement.
|
||||
</p>
|
||||
<?php
|
||||
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
|
||||
// comment c'est stocké en DB (CSV).
|
||||
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
|
||||
$currentDisplay = implode("\n", $currentEmails);
|
||||
?>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_contact_emails">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<div class="field">
|
||||
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
|
||||
<textarea name="contact_emails" rows="6"
|
||||
placeholder="ops@asterionvr.com jerome@client.com technicien@partner.fr"
|
||||
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
|
||||
<p class="hint" style="margin: 4px 0 0;">
|
||||
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
|
||||
Vider le champ = retirer toutes les notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Machines -->
|
||||
<section data-tab="machines" hidden>
|
||||
<p class="section-intro">
|
||||
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
|
||||
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||
La gestion fine machine par machine se fait via le tableau dépliable
|
||||
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
|
||||
</p>
|
||||
<?php if ($l['machines_count'] > 0): ?>
|
||||
<div class="field">
|
||||
<label>Action en bloc</label>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="reset_machines">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
|
||||
<?php endif; ?>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
|
||||
|
||||
<?php
|
||||
// Sous-row dépliable : liste des machines avec remove individuel.
|
||||
@@ -472,6 +649,14 @@ Layout::header('Licenses', 'licenses');
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<?php // Note : pas de "Déplacer vers une autre license" ici —
|
||||
// ce serait une feature DB-only fragile (le launcher
|
||||
// ré-activerait automatiquement la machine sur sa
|
||||
// license d'origine au prochain validate puisque sa
|
||||
// clé license stockée n'a pas changé). La migration
|
||||
// d'une machine vers une autre license se fait côté
|
||||
// launcher : Settings → License → entrer la nouvelle
|
||||
// clé. ?>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
|
||||
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||
|
||||
<!-- Handler global pour les onglets des modals (délégation sur document). -->
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener('click', function (e) {
|
||||
// 1. Tabs des modals d'édition license
|
||||
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||
if (btn) {
|
||||
var tabName = btn.dataset.tab;
|
||||
var modal = btn.closest('dialog');
|
||||
if (!modal) return;
|
||||
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||
b.classList.toggle('active', b === btn);
|
||||
});
|
||||
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||
s.hidden = s.dataset.tab !== tabName;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
|
||||
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
|
||||
var copyBtn = e.target.closest('[data-copy]');
|
||||
if (copyBtn) {
|
||||
var text = copyBtn.dataset.copy || '';
|
||||
var original = copyBtn.textContent;
|
||||
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
|
||||
// Fallback execCommand pour les browsers très anciens.
|
||||
var copyOk = function () {
|
||||
copyBtn.textContent = '✓';
|
||||
copyBtn.style.color = '#16A34A';
|
||||
setTimeout(function () {
|
||||
copyBtn.textContent = original;
|
||||
copyBtn.style.color = '';
|
||||
}, 1500);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(copyOk, function () {
|
||||
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
|
||||
fallbackCopy(text, copyOk);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, copyOk);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
|
||||
// browsers sans avoir besoin de la permission Clipboard API.
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); onSuccess(); }
|
||||
catch (e) { /* silent */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php Layout::footer();
|
||||
|
||||
@@ -3,10 +3,14 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/Layout.php';
|
||||
require __DIR__ . '/lib/Channels.php';
|
||||
require __DIR__ . '/lib/Mailer.php';
|
||||
require __DIR__ . '/../api/lib/Db.php';
|
||||
|
||||
use PSLauncher\Admin\Auth;
|
||||
use PSLauncher\Admin\Layout;
|
||||
use PSLauncher\Admin\Channels;
|
||||
use PSLauncher\Admin\Mailer;
|
||||
use PSLauncher\Db;
|
||||
|
||||
Auth::requireLogin();
|
||||
$config = require __DIR__ . '/../api/config.php';
|
||||
@@ -97,6 +101,162 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le corps HTML de l'email de release announce. Message en anglais
|
||||
* (= langue par défaut du parc client international VR PROSERVE), inclut les
|
||||
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
|
||||
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
|
||||
* Le owner_name de la license est utilisé pour personnaliser le greeting.
|
||||
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
|
||||
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
|
||||
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
|
||||
*/
|
||||
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
|
||||
{
|
||||
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
|
||||
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
|
||||
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
|
||||
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
|
||||
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
|
||||
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
|
||||
// Wrap les release notes en <pre> pour préserver le Markdown formatting
|
||||
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
|
||||
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
|
||||
$notesBlock = $notesEsc !== ''
|
||||
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
|
||||
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
|
||||
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
|
||||
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
|
||||
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
|
||||
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
|
||||
|
||||
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
|
||||
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
|
||||
// en blanc sur le fond bleu et le destinataire voit quand même la marque.
|
||||
$logoBlock = $logoEsc !== ''
|
||||
? "<img src=\"{$logoEsc}\" alt=\"ASTERION VR\" width=\"180\" "
|
||||
. "style=\"display:inline-block; max-height:48px; max-width:240px; height:auto; "
|
||||
. "color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:18px; "
|
||||
. "font-weight:700; text-align:center; line-height:48px;\"/>"
|
||||
: '<span style="color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:22px; font-weight:700;">ASTERION VR</span>';
|
||||
|
||||
// Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste
|
||||
// CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word,
|
||||
// qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres
|
||||
// règles CSS modernes. Le résultat précédent ressemblait à un email plain
|
||||
// text sans bandeau bleu. Avec ce layout :
|
||||
// • <table bgcolor="#1E40AF"> → fond bleu solide visible dans TOUS les MUA
|
||||
// • Styles inline sur chaque <td> → pas de strip Outlook
|
||||
// • width="640" attribute (vs CSS max-width) → respecté par Outlook
|
||||
// • Pas de gradient (impossible OOTB en HTML email standard) — solid blue
|
||||
// URL d'install du launcher (= installer Inno Setup self-extracting Windows).
|
||||
// Servi statiquement par Apache depuis server/installer/. Si l'opérateur
|
||||
// change ce path, mettre à jour ici. Pas de paramétrage config volontaire :
|
||||
// le path est canonique côté projet et tous les launchers en partagent un.
|
||||
$installerUrl = rtrim($baseUrl, '/') !== ''
|
||||
? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe'
|
||||
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
|
||||
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
|
||||
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
|
||||
<tr>
|
||||
<td bgcolor="#0F172A" align="center"
|
||||
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
|
||||
<div style="margin-bottom:16px;">{$logoBlock}</div>
|
||||
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
|
||||
PROSERVE v{$versionEsc} is available!
|
||||
</h1>
|
||||
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
|
||||
A new version is ready to install via your PROSERVE Launcher.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#FFFFFF"
|
||||
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
|
||||
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
|
||||
<p style="margin:0 0 14px;">
|
||||
We're happy to let you know that
|
||||
<strong>PROSERVE version {$versionEsc}</strong>
|
||||
has just been published and is now available for download.
|
||||
</p>
|
||||
<p style="margin:0 0 14px;">
|
||||
If you already have the <strong>PROSERVE Launcher</strong> installed,
|
||||
simply open it on any of your machines, click
|
||||
<em>« Check for updates »</em>, and install the new version with one click.
|
||||
</p>
|
||||
|
||||
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
|
||||
+ brève instruction. Bordure haut/bas pour le détacher visuellement
|
||||
du paragraphe d'install standard ci-dessus. -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||
style="margin:20px 0; border-top:1px solid #E5E7EB; border-bottom:1px solid #E5E7EB;">
|
||||
<tr>
|
||||
<td style="padding:18px 0;">
|
||||
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
|
||||
First install or new workstation?
|
||||
</p>
|
||||
<p style="margin:0 0 16px; color:#4B5563;">
|
||||
If this is your first time, or you're setting up a new PC,
|
||||
download the PROSERVE Launcher installer below.
|
||||
<strong>You'll need to run it on EACH workstation</strong>
|
||||
where PROSERVE will be used.
|
||||
</p>
|
||||
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
|
||||
compat Outlook qui ne supporte ni border-radius CSS sur
|
||||
<a> ni padding CSS bien rendu. Centrage via align="center"
|
||||
sur la table wrapper, qui marche dans Outlook ET les MUA
|
||||
modernes (vs `margin:auto` sur table ignoré par Outlook). -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td bgcolor="#3B82F6" align="center"
|
||||
style="background-color:#3B82F6; border-radius:6px;">
|
||||
<a href="{$installerUrlEsc}"
|
||||
style="display:inline-block; padding:12px 28px;
|
||||
color:#FFFFFF; text-decoration:none;
|
||||
font-family:'Segoe UI',Arial,sans-serif;
|
||||
font-size:14px; font-weight:600;">
|
||||
⬇ Download PROSERVE Launcher (Windows)
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
|
||||
Direct link:
|
||||
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{$notesBlock}
|
||||
<p style="margin:24px 0 14px;">
|
||||
As always, if you run into any issue (download failure, install error,
|
||||
anything weird in-game), don't hesitate to reach out — we're here to help.
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
Happy training!<br/>
|
||||
<strong>The ASTERION VR team</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
|
||||
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
|
||||
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un nom de fichier ZIP saisi par l'admin :
|
||||
* - strip tout chemin (path traversal défense, basename)
|
||||
@@ -133,8 +293,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$isBeta = isset($_POST['is_beta']);
|
||||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||||
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
||||
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||
// Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de
|
||||
// pré-release sans bumper le numéro de release final (ex. 1.5.4.13
|
||||
// pendant le dev, 1.5.4 au release).
|
||||
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) {
|
||||
throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B attendu).');
|
||||
}
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||||
throw new Exception('Date min_license_date invalide.');
|
||||
@@ -357,6 +521,127 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
. "\n" . implode("\n", $resign['log']);
|
||||
if (!$resign['ok']) $messageType = 'error';
|
||||
}
|
||||
elseif ($action === 'notify_release') {
|
||||
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
|
||||
// cette version. Critères d'éligibilité (= license verra la version
|
||||
// dans son manifest signé) :
|
||||
// • Pas révoquée
|
||||
// • download_entitlement_until >= minLicenseDate de la version
|
||||
// • channel de la license ∈ channels de la version (ou « default »)
|
||||
// • can_see_betas = 1 si la version est isBeta=true
|
||||
// Pour chaque license éligible : split contact_emails + send via Mailer.
|
||||
// Compteurs retournés dans le message admin.
|
||||
$entryId = (string)($_POST['id'] ?? '');
|
||||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||||
$entry = $manifest['versions'][$idx];
|
||||
$version = $entry['version'] ?? '?';
|
||||
$isBeta = !empty($entry['isBeta']);
|
||||
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
|
||||
? $entry['channels']
|
||||
: ['default'];
|
||||
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
|
||||
|
||||
// Charge release notes (Markdown brut → on l'envoie tel quel,
|
||||
// les MUA modernes l'affichent OK même sans rendu)
|
||||
$notesFile = "$notesDir/{$entryId}.md";
|
||||
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
|
||||
|
||||
// Récupère les licenses éligibles
|
||||
$db = Db::get($config);
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
|
||||
FROM licenses
|
||||
WHERE revoked_at IS NULL
|
||||
AND download_entitlement_until >= ?
|
||||
AND contact_emails IS NOT NULL
|
||||
AND contact_emails != \'\''
|
||||
);
|
||||
$stmt->execute([$minLic]);
|
||||
$licenses = $stmt->fetchAll();
|
||||
|
||||
$notifConfig = $config['notifications'] ?? [];
|
||||
|
||||
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
|
||||
// SANS demander de permission "Télécharger les images". Le path
|
||||
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
|
||||
// override via $notifConfig['logo_path']. Si le fichier n'existe
|
||||
// pas, $logoCid reste vide → le template skip le bloc image et
|
||||
// utilise le fallback texte "ASTERION VR" en blanc.
|
||||
//
|
||||
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
|
||||
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
|
||||
// il n'offre même pas le bouton « Télécharger les images »
|
||||
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
|
||||
// fallback texte que ce visuel anti-pro.
|
||||
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
|
||||
$logoCid = '';
|
||||
$logoMissing = false;
|
||||
$inlineImages = [];
|
||||
if (is_file($logoPath) && is_readable($logoPath)) {
|
||||
$logoCid = 'asterion-logo@pslauncher';
|
||||
$inlineImages[] = [
|
||||
'cid' => $logoCid,
|
||||
'path' => $logoPath,
|
||||
'type' => 'image/png',
|
||||
];
|
||||
} else {
|
||||
$logoMissing = true;
|
||||
}
|
||||
|
||||
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
|
||||
foreach ($licenses as $lic) {
|
||||
$licChannel = $lic['channel'] ?? 'default';
|
||||
// Match : la license voit la version si son channel est dans
|
||||
// la liste des channels de la version (ou si version='default'
|
||||
// et license sans channel = public)
|
||||
$licChannelEffective = $licChannel ?? 'default';
|
||||
if (!in_array($licChannelEffective, $vChannels, true)) continue;
|
||||
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
|
||||
|
||||
$emails = Mailer::parseEmails($lic['contact_emails']);
|
||||
if (empty($emails)) continue;
|
||||
$licsTouched++;
|
||||
|
||||
$subject = "PROSERVE v{$version} is now available";
|
||||
// Logo via CID inline si fichier disponible sur disque, sinon
|
||||
// chaîne vide → le template tombera sur le fallback texte
|
||||
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
|
||||
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
|
||||
// tout hotlink par défaut et affiche un placeholder cassé
|
||||
// sans même offrir le bouton "Télécharger les images" si
|
||||
// l'URL retourne 404. Cf logoMissing détecté plus haut.
|
||||
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
|
||||
$bodyHtml = renderReleaseAnnounceEmail(
|
||||
$version,
|
||||
$lic['owner_name'] ?? '',
|
||||
$notesBody,
|
||||
$config['base_url'] ?? '',
|
||||
$logoSrc
|
||||
);
|
||||
|
||||
foreach ($emails as $email) {
|
||||
// Dédup : ne pas envoyer 2× à la même adresse si elle est
|
||||
// contact sur plusieurs licenses du même client.
|
||||
if (isset($emailsSet[$email])) continue;
|
||||
$emailsSet[$email] = true;
|
||||
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
|
||||
if ($ok) $totalSent++; else $totalFailed++;
|
||||
}
|
||||
}
|
||||
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
|
||||
"(à {$licsTouched} license(s) éligibles, " .
|
||||
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
|
||||
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
|
||||
// l'opérateur découvre le bug seulement quand un destinataire lui
|
||||
// dit que l'email n'a pas de logo.
|
||||
if ($logoMissing) {
|
||||
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
|
||||
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
|
||||
$messageType = 'error';
|
||||
}
|
||||
if ($totalFailed > 0) $messageType = 'error';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$message = $e->getMessage();
|
||||
$messageType = 'error';
|
||||
@@ -412,16 +697,34 @@ Layout::header('Versions', 'versions');
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="row">
|
||||
<div class="col field">
|
||||
<label>Version (X.Y.Z)</label>
|
||||
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
|
||||
<label>Version (X.Y.Z, ou X.Y.Z.B pour une itération dev)</label>
|
||||
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
|
||||
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
|
||||
</div>
|
||||
<?php
|
||||
// Défauts conventionnels :
|
||||
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
|
||||
// l'entry au moment du release, ça serait absurde d'avoir un
|
||||
// champ vide qu'il faut systématiquement remplir)
|
||||
// • min_license_date = aujourd'hui - 1 an (les licenses sont
|
||||
// valides 1 an, donc une license émise il y a moins d'un an
|
||||
// a accès à cette version par défaut). Modifiable pour les
|
||||
// cas particuliers (release LTS, security patch limité aux
|
||||
// dernières licenses, etc.).
|
||||
$defaultReleasedAt = date('Y-m-d');
|
||||
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
|
||||
?>
|
||||
<div class="col field">
|
||||
<label>Date de release</label>
|
||||
<input type="datetime-local" name="released_at">
|
||||
<input type="date" name="released_at"
|
||||
value="<?= $defaultReleasedAt ?>"
|
||||
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
|
||||
</div>
|
||||
<div class="col field">
|
||||
<label>min_license_date (license requise)</label>
|
||||
<input type="date" name="min_license_date" required>
|
||||
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
|
||||
<input type="date" name="min_license_date" required
|
||||
value="<?= $defaultMinLicDate ?>"
|
||||
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -479,7 +782,7 @@ Layout::header('Versions', 'versions');
|
||||
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<form method="post" style="display:inline">
|
||||
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_versions">
|
||||
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
||||
@@ -558,130 +861,261 @@ Layout::header('Versions', 'versions');
|
||||
</form>
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<?php if ($zipExists && !$hashSkipped): ?>
|
||||
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">⚙ Hash</summary>
|
||||
<div style="margin-top: 8px; min-width: 280px;">
|
||||
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
|
||||
<?php
|
||||
// entryId est utilisé partout (boutons row + modal IDs).
|
||||
$entryId = (string)($v['id'] ?? '');
|
||||
$entryIdEsc = htmlspecialchars($entryId);
|
||||
$modalId = 'edit-' . $entryIdEsc;
|
||||
?>
|
||||
<div class="row-actions">
|
||||
<?php if ($zipExists && !$hashSkipped): ?>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
|
||||
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||
✎ Modifier
|
||||
</button>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
|
||||
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="notify_release">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
|
||||
</form>
|
||||
<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">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<button class="btn btn-danger" type="submit">✕ Retirer</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
|
||||
// — un <dialog> dans un <tr style="display:none"> est exclu du
|
||||
// layout tree par certains browsers, ce qui empêche showModal()
|
||||
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
|
||||
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
|
||||
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||
<header>
|
||||
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
|
||||
<?php if (!empty($v['isBeta'])): ?>
|
||||
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<button type="button" class="close-x" aria-label="Fermer"
|
||||
onclick="this.closest('dialog').close()">×</button>
|
||||
</header>
|
||||
|
||||
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
|
||||
un seul handler global JS déclaré en bas de page (délégation
|
||||
d'événement sur document → pas besoin de rebind par modal). -->
|
||||
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
|
||||
<button type="button" data-tab="meta" class="active">Méta</button>
|
||||
<button type="button" data-tab="notes">Notes</button>
|
||||
<button type="button" data-tab="beta">BÊTA</button>
|
||||
<button type="button" data-tab="channels">Channels</button>
|
||||
<button type="button" data-tab="advanced">Avancé</button>
|
||||
</nav>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
|
||||
<section data-tab="meta">
|
||||
<p class="section-intro">
|
||||
Date d'expiration mini d'une license pour télécharger cette version,
|
||||
date de release publique, et nom du fichier ZIP côté serveur (utile pour
|
||||
invalider le cache CDN après un re-build).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_meta">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
|
||||
<input type="date" name="min_license_date"
|
||||
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Date de release</label>
|
||||
<?php
|
||||
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
|
||||
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
|
||||
// L'heure n'est pas affichée à l'opérateur — toutes les releases
|
||||
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
|
||||
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
|
||||
?>
|
||||
<input type="date" name="released_at"
|
||||
value="<?= htmlspecialchars($releasedDateOnly) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Renommer le ZIP
|
||||
<span class="muted" style="font-weight: normal;">
|
||||
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Notes -->
|
||||
<section data-tab="notes" hidden>
|
||||
<p class="section-intro">
|
||||
Markdown des release notes de cette version. Affichées dans le dialog
|
||||
d'install côté launcher et accessibles via le menu « Notes de release ».
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_notes">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<textarea name="notes" rows="14"
|
||||
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : BÊTA -->
|
||||
<section data-tab="beta" hidden>
|
||||
<p class="section-intro">
|
||||
Une version BÊTA n'est visible que par les licenses qui ont le flag
|
||||
<code>can_see_betas</code> activé. Les autres clients voient la version
|
||||
comme inexistante.
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_beta">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||
Cette version est une BÊTA
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
|
||||
<input type="text" name="beta_notes"
|
||||
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||
placeholder="Tester en priorité…">
|
||||
</div>
|
||||
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Channels -->
|
||||
<section data-tab="channels" hidden>
|
||||
<p class="section-intro">
|
||||
Détermine quelles licenses voient cette version. Coche « default » pour
|
||||
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
|
||||
la page <a href="channels.php">Channels</a>).
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_channels">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<div class="field">
|
||||
<div class="checkbox-list">
|
||||
<?php foreach ($channelRegistry['channels'] as $c):
|
||||
$cn = $c['name'] ?? '';
|
||||
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||||
<code><?= htmlspecialchars($cn) ?></code>
|
||||
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<p class="warn">⚠ Re-signe le manifest après modif.</p>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TAB : Avancé (skip hash + force re-hash) -->
|
||||
<section data-tab="advanced" hidden>
|
||||
<p class="section-intro">
|
||||
Options de vérification d'intégrité. La sécurité repose sur la signature
|
||||
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
|
||||
utile sur très gros builds pour gagner 30s-3min en fin de DL.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label>Vérification SHA-256 chez les clients</label>
|
||||
<p class="hint" style="margin: 0 0 8px;">
|
||||
<?php if ($hashSkipped): ?>
|
||||
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
|
||||
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
|
||||
uniquement sur la signature Ed25519 du manifest + HTTPS.
|
||||
Actuellement <strong style="color: var(--busy);">désactivée</strong>
|
||||
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
|
||||
<?php else: ?>
|
||||
Désactive la vérif SHA-256 chez les clients pour cette release
|
||||
(utile sur très gros builds où on accepte le compromis perf/sécurité).
|
||||
Actuellement <strong style="color: var(--installed);">activée</strong>
|
||||
(default sain).
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<form method="post" style="display:inline">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_skip_hash">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
||||
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
||||
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php if ($zipExists): ?>
|
||||
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
|
||||
</div>
|
||||
<?php if ($zipExists): ?>
|
||||
<div class="field">
|
||||
<label>Forcer un re-hash du ZIP</label>
|
||||
<p class="hint" style="margin: 0 0 8px;">
|
||||
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
|
||||
Utile pour valider une intégrité après transfert SFTP douteux.
|
||||
</p>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="sync_one">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Méta</summary>
|
||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_meta">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<div class="field">
|
||||
<label>min_license_date</label>
|
||||
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Date de release (UTC)</label>
|
||||
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
|
||||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||||
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn btn-secondary">Notes</summary>
|
||||
<form method="post" style="margin-top: 8px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="edit_notes">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></textarea>
|
||||
<br><br>
|
||||
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
||||
</form>
|
||||
</details>
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
|
||||
<form method="post" style="margin-top: 8px; min-width: 360px;">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="set_beta">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<div class="field">
|
||||
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||||
Cette version est une BÊTA
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes pour les testeurs</label>
|
||||
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||||
placeholder="Tester en priorité…">
|
||||
</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 (« 🔁 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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<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">
|
||||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||||
<button class="btn btn-danger" type="submit">Retirer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<?php
|
||||
// Capture le markup du dialog et le pousse dans un buffer global
|
||||
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
|
||||
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
|
||||
// de manière imprévisible par le parser HTML, ce qui peut casser
|
||||
// showModal() (élément non connecté au layout tree).
|
||||
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($manifest['versions'])): ?>
|
||||
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
||||
@@ -690,6 +1124,11 @@ Layout::header('Versions', 'versions');
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
|
||||
// pour que showModal() fonctionne fiablement (le top layer est indépendant
|
||||
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
|
||||
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>ZIPs présents dans builds/</h2>
|
||||
<?php if (empty($zips)): ?>
|
||||
@@ -718,4 +1157,62 @@ Layout::header('Versions', 'versions');
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Overlay busy : visible pendant les opérations longues côté serveur
|
||||
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
|
||||
Affiché en synchrone juste avant le submit du form ; disparaît
|
||||
naturellement au reload de page qui suit la réponse POST. -->
|
||||
<div id="busy-overlay" class="busy-overlay" hidden>
|
||||
<div class="busy-content">
|
||||
<div class="busy-spinner"></div>
|
||||
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
|
||||
<p id="busy-detail">Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.</p>
|
||||
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
|
||||
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Handler global pour les onglets des modals d'édition de version.
|
||||
Délégation d'événement sur document → marche pour tous les .modal-tabs,
|
||||
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||
if (!btn) return;
|
||||
var tabName = btn.dataset.tab;
|
||||
var modal = btn.closest('dialog');
|
||||
if (!modal) return;
|
||||
// Active button → toggle .active sur les boutons du même <nav>
|
||||
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||
b.classList.toggle('active', b === btn);
|
||||
});
|
||||
// Sections → toggle [hidden] selon data-tab
|
||||
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||
s.hidden = s.dataset.tab !== tabName;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
|
||||
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
|
||||
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
|
||||
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
|
||||
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
|
||||
function showBusyOverlay(scope, version) {
|
||||
var overlay = document.getElementById('busy-overlay');
|
||||
if (!overlay) return;
|
||||
var title = document.getElementById('busy-title');
|
||||
var detail = document.getElementById('busy-detail');
|
||||
if (scope === 'bulk') {
|
||||
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
|
||||
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (3–5 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
|
||||
} else {
|
||||
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
|
||||
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 3–5 minutes selon les performances disque du serveur.';
|
||||
}
|
||||
overlay.hidden = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php Layout::footer();
|
||||
|
||||
@@ -40,4 +40,32 @@ return [
|
||||
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
||||
// puis colle le résultat ci-dessous.
|
||||
'admin_password_hash' => '',
|
||||
|
||||
// === NOTIFICATIONS EMAIL (release announce) ===
|
||||
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
|
||||
// contacts d'une license de la sortie d'une nouvelle version.
|
||||
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
|
||||
// OVH mutualisé via le relais SMTP local sans config additionnelle).
|
||||
'notifications' => [
|
||||
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
|
||||
// une adresse hébergée sur ton domaine principal).
|
||||
'from_address' => 'no-reply@asterionvr.com',
|
||||
'from_name' => 'ASTERION VR — PROSERVE Launcher',
|
||||
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
|
||||
// (ex. boîte support partagée). Vide = pas de header Reply-To.
|
||||
'reply_to' => '',
|
||||
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
|
||||
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
|
||||
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
|
||||
// SANS demander la permission « Télécharger les images » au destinataire.
|
||||
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
|
||||
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
|
||||
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
|
||||
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
|
||||
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
|
||||
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
|
||||
// les hotlinks par défaut → le destinataire devra cliquer « afficher
|
||||
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
|
||||
'logo_url' => '',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -49,8 +49,9 @@ if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
|
||||
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
||||
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
|
||||
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
|
||||
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||
require __DIR__ . '/routes/Releasenotes.php';
|
||||
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
||||
return;
|
||||
@@ -64,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
||||
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||
require __DIR__ . '/lib/Db.php';
|
||||
require __DIR__ . '/lib/Crypto.php';
|
||||
require __DIR__ . '/routes/DownloadUrl.php';
|
||||
|
||||
@@ -19,7 +19,8 @@ final class DownloadUrl
|
||||
{
|
||||
public static function handle(array $config, string $version): void
|
||||
{
|
||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
||||
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
|
||||
Response::error('invalid_version', 'Version invalide', 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ final class Releasenotes
|
||||
public static function handle(string $key): void
|
||||
{
|
||||
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
||||
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
|
||||
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
|
||||
|
||||
if (!$isEntryId && !$isVersion) {
|
||||
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
||||
|
||||
18
server/migrations/004_license_contact_emails.sql
Normal file
18
server/migrations/004_license_contact_emails.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- PS_Launcher schema v4
|
||||
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
|
||||
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
|
||||
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
|
||||
--
|
||||
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
|
||||
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
|
||||
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
|
||||
-- overkill pour une feature de notification opportuniste sans relations.
|
||||
--
|
||||
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
|
||||
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||
|
||||
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
|
||||
|
||||
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
|
||||
-- à notifier → comportement identique à avant. L'admin doit explicitement
|
||||
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».
|
||||
51
server/releasenotes/1.5.4.md
Normal file
51
server/releasenotes/1.5.4.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# PROSERVE v1.5.4
|
||||
|
||||
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
|
||||
performance improvements across all scenarios.
|
||||
|
||||
This release is packed with new tooling and a major rework of our AI
|
||||
systems. Here's what's new:
|
||||
|
||||
## ✨ New features
|
||||
|
||||
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
|
||||
building and tweaking scenarios directly inside PROSERVE.
|
||||
*Still alpha — feedback welcome.*
|
||||
|
||||
- **AI Autonomous Agent — full rework** — smarter decision-making,
|
||||
more believable behaviors, smoother pathfinding and reactions.
|
||||
|
||||
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
|
||||
language. First iteration, expect rapid improvements over the
|
||||
upcoming releases.
|
||||
|
||||
- **PS_Launcher** — new companion tool that simplifies version
|
||||
updates and the launch of PROSERVE on each workstation. Auto-detects
|
||||
new releases, handles install + redists, and surfaces stats and
|
||||
documentation in one place.
|
||||
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
|
||||
|
||||
- **PS_Report** — view, export, and print your mission results and
|
||||
trainee statistics directly from the launcher.
|
||||
|
||||
## 🚀 Gameplay improvements
|
||||
|
||||
- **Aiming** — reduced reaction time and improved shooting precision
|
||||
across all firearms.
|
||||
|
||||
- **Character gaze** — characters now realistically track the
|
||||
trainee and points of interest, adding presence and immersion to
|
||||
every interaction.
|
||||
|
||||
- **Hose behavior (Firefighter edition)** — reworked physics and
|
||||
grip feel for the firehose; more predictable, less jittery, more
|
||||
satisfying to handle.
|
||||
|
||||
## 🌍 Localization
|
||||
|
||||
- **Thai language** added — full UI translation.
|
||||
|
||||
---
|
||||
|
||||
*As always, your feedback is invaluable. If you spot any issue or
|
||||
have suggestions, reach out to your ASTERION VR account manager.*
|
||||
@@ -56,16 +56,26 @@ final class SignManifest
|
||||
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||||
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||||
*
|
||||
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
|
||||
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
|
||||
* supplantait silencieusement le `--force` du caller — bug subtil parce
|
||||
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
|
||||
* ou force=true), mais ce helper retombait sur sa propre cache lookup
|
||||
* (clé = realpath) → retournait instantanément l'ancien hash sans
|
||||
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
|
||||
* SFTP avec mtime préservé.
|
||||
*
|
||||
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||||
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||||
*/
|
||||
private function getOrComputeSha256(string $path, array &$cache): array
|
||||
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
|
||||
{
|
||||
$size = filesize($path) ?: 0;
|
||||
$mtime = filemtime($path) ?: 0;
|
||||
$key = realpath($path) ?: $path;
|
||||
|
||||
if (isset($cache[$key])
|
||||
if (!$force
|
||||
&& isset($cache[$key])
|
||||
&& ($cache[$key]['size'] ?? -1) === $size
|
||||
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||||
&& !empty($cache[$key]['sha256'])) {
|
||||
@@ -192,7 +202,7 @@ final class SignManifest
|
||||
}
|
||||
}
|
||||
|
||||
$r = $this->getOrComputeSha256($zip, $cache);
|
||||
$r = $this->getOrComputeSha256($zip, $cache, $force);
|
||||
$cacheChanged = true;
|
||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||||
@@ -231,7 +241,7 @@ final class SignManifest
|
||||
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||||
} else {
|
||||
$r = $this->getOrComputeSha256($lpath, $cache);
|
||||
$r = $this->getOrComputeSha256($lpath, $cache, $force);
|
||||
$cacheChanged = true;
|
||||
$launcher['download']['sha256'] = $r['sha256'];
|
||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||
|
||||
@@ -70,7 +70,23 @@ foreach ($files as $f) {
|
||||
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
||||
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
||||
foreach ($statements as $stmt) {
|
||||
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
|
||||
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
|
||||
// de checker si le statement est vide. Sinon : un fichier migration qui
|
||||
// commence par un header de commentaires se retrouve dans le PREMIER
|
||||
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
|
||||
// commence donc par `--`, le check str_starts_with le déclare vide,
|
||||
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
|
||||
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
|
||||
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
|
||||
$lines = preg_split('/\r?\n/', $stmt) ?: [];
|
||||
$kept = [];
|
||||
foreach ($lines as $line) {
|
||||
$t = ltrim($line);
|
||||
if ($t === '' || str_starts_with($t, '--')) continue;
|
||||
$kept[] = $line;
|
||||
}
|
||||
$stmt = trim(implode("\n", $kept));
|
||||
if ($stmt === '') continue;
|
||||
try {
|
||||
$pdo->exec($stmt);
|
||||
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
||||
|
||||
Reference in New Issue
Block a user