Integration Baniere dans le backend
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
/**
|
||||
* Choix de la bannière d'un agent parmi les campagnes en cours (sans dépendance au framework).
|
||||
* Règle : une campagne sans groupe cible tous les agents ; sinon l'agent doit appartenir à l'un de ses groupes.
|
||||
* Une seule bannière par signature : la priorité la plus haute l'emporte ;
|
||||
* à priorité égale, la campagne la plus ancienne (id le plus petit) gagne, pour un résultat stable.
|
||||
*/
|
||||
final class CampaignSelector
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id:int, priority:int, group_ids:array<int, int>}> $campaigns campagnes en cours
|
||||
* @param array<int, int> $userGroupIds
|
||||
*/
|
||||
public function select(array $campaigns, array $userGroupIds): ?int
|
||||
{
|
||||
$groups = array_flip($userGroupIds);
|
||||
$best = null;
|
||||
|
||||
foreach ($campaigns as $c) {
|
||||
if ($c['group_ids'] !== [] && ! array_intersect_key($groups, array_flip($c['group_ids']))) {
|
||||
continue;
|
||||
}
|
||||
if ($best === null
|
||||
|| $c['priority'] > $best['priority']
|
||||
|| ($c['priority'] === $best['priority'] && $c['id'] < $best['id'])) {
|
||||
$best = $c;
|
||||
}
|
||||
}
|
||||
|
||||
return $best['id'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Signature;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Bibliothèque des images de signature (logos, bannières), servies publiquement par Nginx sous /img.
|
||||
* Formats acceptés : PNG, JPEG, GIF. Pas de SVG (mal supporté par Outlook et vecteur de script),
|
||||
* pas de WebP (non affiché par plusieurs clients de messagerie).
|
||||
*/
|
||||
final class ImageLibrary
|
||||
{
|
||||
public const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif'];
|
||||
|
||||
/** Règles de validation d'un fichier déposé. */
|
||||
public static function uploadRules(): array
|
||||
{
|
||||
return ['file', 'mimes:png,jpg,jpeg,gif', 'mimetypes:image/png,image/jpeg,image/gif', 'dimensions:min_width=1', 'max:'.config('signature.image_max_kb')];
|
||||
}
|
||||
|
||||
/** Messages associés, à préfixer par le nom du champ. */
|
||||
public static function uploadMessages(string $field): array
|
||||
{
|
||||
return [
|
||||
"{$field}.mimes" => 'Formats acceptés : PNG, JPEG ou GIF.',
|
||||
"{$field}.mimetypes" => 'Le fichier n\'est pas une image PNG, JPEG ou GIF valide.',
|
||||
"{$field}.dimensions" => 'Image illisible : le fichier est incomplet ou endommagé.',
|
||||
"{$field}.uploaded" => 'L\'envoi a échoué : fichier trop lourd pour le serveur.',
|
||||
"{$field}.max" => 'Image trop lourde (2 Mo maximum ; visez moins de 100 Ko pour une signature).',
|
||||
];
|
||||
}
|
||||
|
||||
public function dir(): string
|
||||
{
|
||||
$dir = config('signature.image_path');
|
||||
File::ensureDirectoryExists($dir);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function path(string $name): string
|
||||
{
|
||||
return $this->dir().DIRECTORY_SEPARATOR.basename($name);
|
||||
}
|
||||
|
||||
public function exists(string $name): bool
|
||||
{
|
||||
return $name !== '' && $name === basename($name)
|
||||
&& in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::EXTENSIONS, true)
|
||||
&& File::isFile($this->path($name));
|
||||
}
|
||||
|
||||
public function url(string $name): string
|
||||
{
|
||||
return config('signature.public_url').'/img/'.rawurlencode($name);
|
||||
}
|
||||
|
||||
/** Largeur réelle en pixels, ou null si le fichier est illisible. */
|
||||
public function width(string $name): ?int
|
||||
{
|
||||
$size = $this->exists($name) ? @getimagesize($this->path($name)) : false;
|
||||
|
||||
return $size ? (int) $size[0] : null;
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{name:string, url:string, kb:int, width:?int, height:?int, modified:int}> */
|
||||
public function all(): Collection
|
||||
{
|
||||
return collect(File::files($this->dir()))
|
||||
->filter(fn ($f) => in_array(strtolower($f->getExtension()), self::EXTENSIONS, true))
|
||||
->map(function ($f) {
|
||||
$size = @getimagesize($f->getPathname()) ?: [null, null];
|
||||
|
||||
return [
|
||||
'name' => $f->getFilename(),
|
||||
'url' => $this->url($f->getFilename()),
|
||||
'kb' => (int) ceil($f->getSize() / 1024),
|
||||
'width' => $size[0],
|
||||
'height' => $size[1],
|
||||
'modified' => $f->getMTime(),
|
||||
];
|
||||
})
|
||||
->sortByDesc('modified')->values();
|
||||
}
|
||||
|
||||
/** Enregistre un fichier déjà validé (uploadRules) sous un nom nettoyé, sans écraser l'existant. Retourne le nom final. */
|
||||
public function store(UploadedFile $file, ?string $name = null): string
|
||||
{
|
||||
// Extension déduite du contenu réel, jamais du nom envoyé par le navigateur.
|
||||
$info = @getimagesize($file->getRealPath());
|
||||
$ext = [IMAGETYPE_PNG => 'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_GIF => 'gif'][$info[2] ?? 0] ?? null;
|
||||
abort_unless($ext, 422, 'Image illisible.');
|
||||
$base = Str::slug(pathinfo($name ?: $file->getClientOriginalName(), PATHINFO_FILENAME)) ?: 'image';
|
||||
|
||||
$stored = "{$base}.{$ext}";
|
||||
for ($i = 2; File::exists($this->path($stored)); $i++) {
|
||||
$stored = "{$base}-{$i}.{$ext}";
|
||||
}
|
||||
|
||||
$file->move($this->dir(), $stored);
|
||||
@chmod($this->path($stored), 0644);
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
public function delete(string $name): void
|
||||
{
|
||||
File::delete($this->path($name));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ final class SignatureBuilder
|
||||
public function __construct(
|
||||
private readonly TemplateRenderer $renderer,
|
||||
private readonly TemplateSelector $selector,
|
||||
private readonly CampaignSelector $campaigns,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,7 +69,8 @@ final class SignatureBuilder
|
||||
public function variables(DirectoryUser $user, string $variant = 'full'): array
|
||||
{
|
||||
$vars = VariableMap::build($user->getAttributes(), $this->allowedOverrides($user));
|
||||
$vars['banniere'] = $variant === 'full' ? $this->bannerHtml($user) : '';
|
||||
$campaign = $variant === 'full' ? $this->campaignFor($user) : null;
|
||||
$vars['banniere'] = $campaign ? $this->bannerHtml($campaign) : '';
|
||||
$vars['site_url'] = config('signature.public_url');
|
||||
|
||||
return $vars;
|
||||
@@ -129,27 +131,38 @@ final class SignatureBuilder
|
||||
return SharedMailbox::with('template')->where('tenant_id', $user->tenant_id)->where('address', $mailbox)->first();
|
||||
}
|
||||
|
||||
private function bannerHtml(DirectoryUser $user): string
|
||||
/** Campagne dont la bannière s'affiche pour cet agent en ce moment (une seule par signature). */
|
||||
public function campaignFor(DirectoryUser $user): ?Campaign
|
||||
{
|
||||
$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 '';
|
||||
$running = Campaign::running()->where('tenant_id', $user->tenant_id)->with('groups:id')->get();
|
||||
if ($running->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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\">";
|
||||
$id = $this->campaigns->select(
|
||||
$running->map(fn (Campaign $c) => [
|
||||
'id' => $c->id,
|
||||
'priority' => $c->priority,
|
||||
'group_ids' => $c->groups->pluck('id')->all(),
|
||||
])->all(),
|
||||
$user->groups()->pluck('directory_groups.id')->all(),
|
||||
);
|
||||
|
||||
return $campaign->link_url
|
||||
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'">'.$img.'</a>'
|
||||
return $id ? $running->firstWhere('id', $id) : null;
|
||||
}
|
||||
|
||||
/** HTML de la bannière d'une campagne : seul bloc injecté sans échappement, donc tout y est échappé ici. */
|
||||
public function bannerHtml(Campaign $campaign): string
|
||||
{
|
||||
$path = implode('/', array_map('rawurlencode', explode('/', ltrim($campaign->image_path, '/'))));
|
||||
$src = htmlspecialchars(config('signature.public_url').'/img/'.$path, ENT_QUOTES);
|
||||
$alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES);
|
||||
$width = $campaign->displayWidth();
|
||||
$img = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"{$width}\" style=\"display:block;width:{$width}px;max-width:100%;height:auto;border:0\">";
|
||||
|
||||
// Lien : http(s) uniquement, même si la valeur a été écrite en base hors du back-office.
|
||||
return preg_match('#^https?://#i', (string) $campaign->link_url)
|
||||
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'" target="_blank" rel="noopener">'.$img.'</a>'
|
||||
: $img;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user