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:
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}>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user