45
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
use App\Models\Assignment;
|
||||
use App\Models\Campaign;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\EditableField;
|
||||
use App\Models\SharedMailbox;
|
||||
use App\Models\Template;
|
||||
|
||||
/**
|
||||
* Calcule la signature d'un agent pour un contexte de rédaction donné.
|
||||
* Tout est lu dans la base locale (synchro Graph) : aucun appel Graph en direct.
|
||||
*/
|
||||
final class SignatureBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TemplateRenderer $renderer,
|
||||
private readonly TemplateSelector $selector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $context new | reply | forward | appointment
|
||||
* @param string|null $mailbox adresse émettrice (boîte partagée éventuelle)
|
||||
* @return array{html:string, variant:string, template:string}|null
|
||||
*/
|
||||
public function build(DirectoryUser $user, string $context, ?string $mailbox = null): ?array
|
||||
{
|
||||
$shared = $this->sharedMailbox($user, $mailbox);
|
||||
$template = $shared?->template ?? $this->templateFor($user);
|
||||
if (! $template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variant = $this->variant($user, $context);
|
||||
if ($variant === 'none') {
|
||||
return ['html' => '', 'variant' => 'none', 'template' => $template->name];
|
||||
}
|
||||
|
||||
$source = $variant === 'short' ? ($template->html_short ?: $template->html_full) : $template->html_full;
|
||||
if (! $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vars = $this->variables($user, $variant);
|
||||
|
||||
if ($shared) {
|
||||
// Boîte partagée : ses propres coordonnées, l'agent seulement si l'admin l'a choisi.
|
||||
$vars['boite_nom'] = $shared->display_name;
|
||||
$vars['boite_email'] = $shared->address;
|
||||
$vars['email'] = $shared->address;
|
||||
if (! $shared->include_sender) {
|
||||
foreach (['prenom', 'nom', 'nom_complet', 'fonction', 'mobile'] as $k) {
|
||||
$vars[$k] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'html' => $this->renderer->render($source, $vars),
|
||||
'variant' => $variant,
|
||||
'template' => $template->name,
|
||||
];
|
||||
}
|
||||
|
||||
/** Variables d'un agent (attributs Entra + surcharges autorisées + bannière en variante complète). */
|
||||
public function variables(DirectoryUser $user, string $variant = 'full'): array
|
||||
{
|
||||
$vars = VariableMap::build($user->getAttributes(), $this->allowedOverrides($user));
|
||||
$vars['banniere'] = $variant === 'full' ? $this->bannerHtml($user) : '';
|
||||
$vars['site_url'] = config('signature.public_url');
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/** Aperçu d'un HTML brut (brouillon en cours d'édition) pour un agent donné. */
|
||||
public function preview(DirectoryUser $user, string $source, string $variant = 'full'): string
|
||||
{
|
||||
return $this->renderer->render($source, $this->variables($user, $variant));
|
||||
}
|
||||
|
||||
public function templateFor(DirectoryUser $user): ?Template
|
||||
{
|
||||
$assignments = Assignment::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->get(['id', 'template_id', 'directory_group_id', 'priority'])
|
||||
->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'template_id' => $a->template_id,
|
||||
'group_id' => $a->directory_group_id,
|
||||
'priority' => $a->priority,
|
||||
])->all();
|
||||
|
||||
$default = Template::where('tenant_id', $user->tenant_id)->where('is_default', true)->value('id');
|
||||
$id = $this->selector->select($assignments, $user->groups()->pluck('directory_groups.id')->all(), $default);
|
||||
|
||||
return $id ? Template::whereNotNull('html_full')->find($id) : null;
|
||||
}
|
||||
|
||||
private function variant(DirectoryUser $user, string $context): string
|
||||
{
|
||||
if ($context !== 'reply' && $context !== 'forward') {
|
||||
return 'full';
|
||||
}
|
||||
$pref = $user->preference?->reply_variant ?? config('signature.default_reply_variant', 'short');
|
||||
|
||||
return in_array($pref, ['short', 'full', 'none'], true) ? $pref : 'short';
|
||||
}
|
||||
|
||||
/** Surcharges saisies par l'agent, limitées aux champs que l'admin a ouverts. */
|
||||
private function allowedOverrides(DirectoryUser $user): array
|
||||
{
|
||||
$enabled = EditableField::where('tenant_id', $user->tenant_id)->where('enabled', true)->pluck('field')->all();
|
||||
|
||||
return $user->overrides()->whereIn('field', $enabled)->pluck('value', 'field')->all();
|
||||
}
|
||||
|
||||
private function sharedMailbox(DirectoryUser $user, ?string $mailbox): ?SharedMailbox
|
||||
{
|
||||
if (! $mailbox) {
|
||||
return null;
|
||||
}
|
||||
$mailbox = strtolower($mailbox);
|
||||
if ($mailbox === strtolower((string) $user->mail) || $mailbox === strtolower((string) $user->user_principal_name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SharedMailbox::with('template')->where('tenant_id', $user->tenant_id)->where('address', $mailbox)->first();
|
||||
}
|
||||
|
||||
private function bannerHtml(DirectoryUser $user): string
|
||||
{
|
||||
$groupIds = $user->groups()->pluck('directory_groups.id')->all();
|
||||
|
||||
$campaign = Campaign::running()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->where(fn ($q) => $q->whereDoesntHave('groups')
|
||||
->orWhereHas('groups', fn ($g) => $g->whereIn('directory_groups.id', $groupIds)))
|
||||
->orderByDesc('priority')->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $campaign) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$src = htmlspecialchars(config('signature.public_url').'/img/'.ltrim($campaign->image_path, '/'), ENT_QUOTES);
|
||||
$alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES);
|
||||
$img = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"600\" style=\"display:block;max-width:100%;height:auto;border:0\">";
|
||||
|
||||
return $campaign->link_url
|
||||
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'">'.$img.'</a>'
|
||||
: $img;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Moteur de rendu des templates de signature (sans dépendance au framework).
|
||||
*
|
||||
* Syntaxe :
|
||||
* {{variable}} valeur échappée HTML
|
||||
* {{{variable}}} valeur brute (réservé aux blocs générés par le serveur, ex. banniere)
|
||||
* {{#if variable}} … {{/if}} bloc conservé si la variable est non vide (imbrication possible)
|
||||
* {{#unless variable}} … {{/unless}} bloc conservé si la variable est vide
|
||||
*
|
||||
* Une variable inconnue est traitée comme vide : une ligne « Mobile : {{mobile}} »
|
||||
* entourée de {{#if mobile}} disparaît donc proprement quand le champ n'est pas renseigné.
|
||||
*/
|
||||
final class TemplateRenderer
|
||||
{
|
||||
private const BLOCK = '/\{\{#(if|unless)\s+([a-z0-9_]+)\s*\}\}((?:(?!\{\{#(?:if|unless)\s).)*?)\{\{\/\1\}\}/is';
|
||||
|
||||
/** @param array<string, scalar|null> $vars */
|
||||
public function render(string $template, array $vars): string
|
||||
{
|
||||
$vars = array_change_key_case($vars, CASE_LOWER);
|
||||
|
||||
// Blocs conditionnels, du plus interne vers l'extérieur.
|
||||
$guard = 0;
|
||||
while (preg_match(self::BLOCK, $template) && $guard++ < 50) {
|
||||
$template = preg_replace_callback(self::BLOCK, function (array $m) use ($vars) {
|
||||
$filled = $this->filled($vars[strtolower($m[2])] ?? null);
|
||||
$keep = strtolower($m[1]) === 'if' ? $filled : ! $filled;
|
||||
|
||||
return $keep ? $m[3] : '';
|
||||
}, $template);
|
||||
}
|
||||
|
||||
// Valeurs brutes puis échappées.
|
||||
$template = preg_replace_callback('/\{\{\{\s*([a-z0-9_]+)\s*\}\}\}/i', fn ($m) => (string) ($vars[strtolower($m[1])] ?? ''), $template);
|
||||
|
||||
return preg_replace_callback('/\{\{\s*([a-z0-9_]+)\s*\}\}/i', fn ($m) => htmlspecialchars((string) ($vars[strtolower($m[1])] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), $template);
|
||||
}
|
||||
|
||||
/** Liste des variables utilisées par un template (aide à l'édition). */
|
||||
public function variables(string $template): array
|
||||
{
|
||||
preg_match_all('/\{\{\{?\s*(?:#(?:if|unless)\s+)?([a-z0-9_]+)\s*\}?\}\}/i', $template, $m);
|
||||
|
||||
return array_values(array_unique(array_map('strtolower', $m[1])));
|
||||
}
|
||||
|
||||
private function filled(mixed $v): bool
|
||||
{
|
||||
return $v !== null && trim((string) $v) !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Nettoie le HTML d'un template avant enregistrement (sans dépendance au framework).
|
||||
*
|
||||
* Volontairement basé sur des expressions régulières et non sur DOMDocument :
|
||||
* un parseur HTML déplacerait les marqueurs {{#if}} placés entre deux <tr>,
|
||||
* ce qui casserait les templates.
|
||||
*
|
||||
* Défense en profondeur : les aperçus sont en plus affichés dans des iframes « sandbox ».
|
||||
*/
|
||||
final class TemplateSanitizer
|
||||
{
|
||||
/** Balises retirées avec leur contenu. */
|
||||
private const BLOCK_TAGS = ['script', 'iframe', 'object', 'embed', 'applet', 'form', 'noscript', 'template', 'svg', 'math'];
|
||||
|
||||
/** Balises orphelines retirées. */
|
||||
private const VOID_TAGS = ['base', 'meta', 'link', 'input', 'button', 'select', 'textarea', 'frame', 'frameset'];
|
||||
|
||||
public function clean(string $html): string
|
||||
{
|
||||
foreach (self::BLOCK_TAGS as $tag) {
|
||||
$html = preg_replace('#<'.$tag.'\b[^>]*>.*?</'.$tag.'\s*>#is', '', $html);
|
||||
$html = preg_replace('#<'.$tag.'\b[^>]*/?>#i', '', $html); // balise ouvrante sans fermeture
|
||||
}
|
||||
foreach (self::VOID_TAGS as $tag) {
|
||||
$html = preg_replace('#</?'.$tag.'\b[^>]*>#i', '', $html);
|
||||
}
|
||||
|
||||
// Gestionnaires d'événements : onclick=, onerror=…
|
||||
$html = preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
|
||||
|
||||
// URL dangereuses dans href / src / action / style url()
|
||||
$html = preg_replace_callback(
|
||||
'#\s(href|src|action|formaction|xlink:href|background)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i',
|
||||
function (array $m) {
|
||||
$value = html_entity_decode($m[3] !== '' ? $m[3] : ($m[4] ?? $m[5] ?? ''), ENT_QUOTES | ENT_HTML5);
|
||||
$normalized = strtolower(preg_replace('/[\s\x00-\x1f]+/', '', $value));
|
||||
|
||||
return preg_match('#^(javascript|vbscript|data):#', $normalized) && ! str_starts_with($normalized, 'data:image/')
|
||||
? ''
|
||||
: $m[0];
|
||||
},
|
||||
$html,
|
||||
);
|
||||
$html = preg_replace('#expression\s*\(|javascript\s*:#i', '', $html);
|
||||
|
||||
return trim($html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Choix du template d'un agent à partir de ses groupes (sans dépendance au framework).
|
||||
* Règle : parmi les affectations dont le groupe contient l'agent, la priorité la plus haute l'emporte ;
|
||||
* à priorité égale, l'affectation la plus ancienne (id le plus petit) gagne, pour un résultat stable.
|
||||
* Aucun groupe correspondant : template par défaut du tenant (ou null).
|
||||
*/
|
||||
final class TemplateSelector
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id:int, template_id:int, group_id:int, priority:int}> $assignments
|
||||
* @param array<int, int> $userGroupIds
|
||||
*/
|
||||
public function select(array $assignments, array $userGroupIds, ?int $defaultTemplateId): ?int
|
||||
{
|
||||
$groups = array_flip($userGroupIds);
|
||||
$best = null;
|
||||
|
||||
foreach ($assignments as $a) {
|
||||
if (! isset($groups[$a['group_id']])) {
|
||||
continue;
|
||||
}
|
||||
if ($best === null
|
||||
|| $a['priority'] > $best['priority']
|
||||
|| ($a['priority'] === $best['priority'] && $a['id'] < $best['id'])) {
|
||||
$best = $a;
|
||||
}
|
||||
}
|
||||
|
||||
return $best['template_id'] ?? $defaultTemplateId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Variables disponibles dans les templates et correspondance avec les attributs Entra.
|
||||
* La clé est celle utilisée dans le template ({{telephone}}) et dans les surcharges agent.
|
||||
*/
|
||||
final class VariableMap
|
||||
{
|
||||
public const FIELDS = [
|
||||
'prenom' => ['column' => 'given_name', 'label' => 'Prénom'],
|
||||
'nom' => ['column' => 'surname', 'label' => 'Nom'],
|
||||
'nom_complet' => ['column' => 'display_name', 'label' => 'Nom complet'],
|
||||
'fonction' => ['column' => 'job_title', 'label' => 'Fonction'],
|
||||
'service' => ['column' => 'department', 'label' => 'Service'],
|
||||
'organisation' => ['column' => 'company_name', 'label' => 'Organisation'],
|
||||
'bureau' => ['column' => 'office_location', 'label' => 'Bureau / site'],
|
||||
'telephone' => ['column' => 'business_phone', 'label' => 'Téléphone fixe'],
|
||||
'mobile' => ['column' => 'mobile_phone', 'label' => 'Téléphone mobile'],
|
||||
'email' => ['column' => 'mail', 'label' => 'Adresse e-mail'],
|
||||
'adresse' => ['column' => 'street_address', 'label' => 'Adresse'],
|
||||
'code_postal' => ['column' => 'postal_code', 'label' => 'Code postal'],
|
||||
'ville' => ['column' => 'city', 'label' => 'Ville'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user attributs de directory_users
|
||||
* @param array<string, string|null> $overrides surcharges agent autorisées (clé de variable => valeur)
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
public static function build(array $user, array $overrides = []): array
|
||||
{
|
||||
$vars = [];
|
||||
foreach (self::FIELDS as $key => $def) {
|
||||
$vars[$key] = $user[$def['column']] ?? null;
|
||||
}
|
||||
foreach ($overrides as $key => $value) {
|
||||
if (array_key_exists($key, self::FIELDS)) {
|
||||
$vars[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user