diff --git a/.gitignore b/.gitignore index 845d9ee..f02ef71 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docker/certs/* deploy.env docker-compose.override.yml docker/nginx/admin-allow.inc +/.env diff --git a/backend/app/Http/Controllers/Admin/ImageController.php b/backend/app/Http/Controllers/Admin/ImageController.php new file mode 100644 index 0000000..fdeaa24 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/ImageController.php @@ -0,0 +1,122 @@ +dir(); + $images = collect(File::files($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' => config('signature.public_url').'/img/'.rawurlencode($f->getFilename()), + 'kb' => (int) ceil($f->getSize() / 1024), + 'width' => $size[0], + 'height' => $size[1], + 'modified' => $f->getMTime(), + 'used_in' => $this->usages($f->getFilename()), + ]; + }) + ->sortByDesc('modified')->values(); + + return view('admin.images.index', ['images' => $images, 'maxKb' => config('signature.image_max_kb')]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'image' => ['required', 'file', 'mimes:png,jpg,jpeg,gif', 'mimetypes:image/png,image/jpeg,image/gif', 'max:'.config('signature.image_max_kb')], + 'name' => ['nullable', 'string', 'max:80'], + ], [ + 'image.mimes' => 'Formats acceptés : PNG, JPEG ou GIF.', + 'image.mimetypes' => 'Le fichier n\'est pas une image PNG, JPEG ou GIF valide.', + 'image.max' => 'Image trop lourde (2 Mo maximum ; visez moins de 100 Ko pour une signature).', + ]); + + $file = $request->file('image'); + abort_unless(@getimagesize($file->getRealPath()), 422, 'Image illisible.'); + + $ext = strtolower($file->getClientOriginalExtension()) === 'jpeg' ? 'jpg' : strtolower($file->getClientOriginalExtension()); + $base = Str::slug(pathinfo($request->input('name') ?: $file->getClientOriginalName(), PATHINFO_FILENAME)) ?: 'image'; + $name = $this->uniqueName($base, $ext); + + $file->move($this->dir(), $name); + @chmod($this->dir().DIRECTORY_SEPARATOR.$name, 0644); + AuditLog::record('admin', $this->actor(), 'image.upload', null, ['file' => $name], $this->tenantId()); + + return back()->with('status', "Image « {$name} » en ligne. Dans un template : {{site_url}}/img/{$name}"); + } + + public function destroy(string $name): RedirectResponse + { + $name = basename($name); + $path = $this->dir().DIRECTORY_SEPARATOR.$name; + abort_unless(File::isFile($path) && in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), self::EXTENSIONS, true), 404); + + if ($used = $this->usages($name)) { + return back()->withErrors(['image' => "« {$name} » est utilisée par : ".implode(', ', $used).'. Retirez-la d\'abord.']); + } + + File::delete($path); + AuditLog::record('admin', $this->actor(), 'image.delete', null, ['file' => $name], $this->tenantId()); + + return back()->with('status', "Image « {$name} » supprimée."); + } + + private function dir(): string + { + $dir = config('signature.image_path'); + File::ensureDirectoryExists($dir); + + return $dir; + } + + private function uniqueName(string $base, string $ext): string + { + $name = "{$base}.{$ext}"; + for ($i = 2; File::exists($this->dir().DIRECTORY_SEPARATOR.$name); $i++) { + $name = "{$base}-{$i}.{$ext}"; + } + + return $name; + } + + /** Templates (publiés ou brouillons) et campagnes qui référencent l'image. */ + private function usages(string $name): array + { + $like = '%/img/'.$name.'%'; + $templates = Template::where('tenant_id', $this->tenantId()) + ->where(fn ($q) => $q->where('html_full', 'like', $like)->orWhere('html_short', 'like', $like) + ->orWhere('draft_html_full', 'like', $like)->orWhere('draft_html_short', 'like', $like)) + ->pluck('name')->map(fn ($n) => "template {$n}"); + $campaigns = Campaign::where('tenant_id', $this->tenantId())->where('image_path', $name) + ->pluck('name')->map(fn ($n) => "campagne {$n}"); + + return $templates->merge($campaigns)->all(); + } +} diff --git a/backend/config/signature.php b/backend/config/signature.php index 7bc84eb..1eb7d26 100644 --- a/backend/config/signature.php +++ b/backend/config/signature.php @@ -48,6 +48,10 @@ return [ // Variante utilisée en réponse / transfert quand l'agent n'a rien choisi : short | full | none 'default_reply_variant' => env('SIGNATURE_DEFAULT_REPLY_VARIANT', 'short'), + // Dossier des images de signature (servi par Nginx sous /img). Surchargé dans les tests. + 'image_path' => env('SIGNATURE_IMAGE_PATH', public_path('img')), + 'image_max_kb' => 2048, + // URL publique (images, liens). 'public_url' => rtrim((string) env('APP_URL', 'https://signature.beziers-mediterranee.fr'), '/'), diff --git a/backend/resources/views/admin/images/index.blade.php b/backend/resources/views/admin/images/index.blade.php new file mode 100644 index 0000000..596e82b --- /dev/null +++ b/backend/resources/views/admin/images/index.blade.php @@ -0,0 +1,79 @@ +@extends('layouts.app') +@section('title', 'Images — Signatures CABM') +@section('content') + @php + $tag = fn (string $inner) => '{'.'{'.$inner.'}'.'}'; + $isAdmin = auth()->user()->hasRole('admin'); + @endphp +
+ Logos et bannières utilisés dans les signatures, publiés sur {{ config('signature.public_url') }}/img/.
+ Les destinataires les chargent depuis ce serveur : visez moins de 100 Ko et la largeur réelle d'affichage (ex. 120 px pour un logo, 600 px pour une bannière).
+
| Aperçu | Fichier | À coller dans un template | Utilisée par | |
|---|---|---|---|---|
|
+ {{ $img['name'] }}
+ {{ $img['width'] }}×{{ $img['height'] }} px · {{ $img['kb'] }} Ko
+ @if ($img['kb'] > 150) lourde @endif
+ |
+
+
+
+ {{ $snippet }}
+
+ |
+ {{ $img['used_in'] ? implode(', ', $img['used_in']) : '—' }} | ++ @if ($isAdmin) + + @endif + | +|
| Aucune image. Commencez par le logo (ex. logo-cabm.png, utilisé par le template par défaut). | ||||