ajout images

This commit is contained in:
mrKamoo
2026-10-01 08:45:49 +02:00
parent e133c741b8
commit fb9e9ee54d
8 changed files with 304 additions and 0 deletions
+1
View File
@@ -8,3 +8,4 @@ docker/certs/*
deploy.env
docker-compose.override.yml
docker/nginx/admin-allow.inc
/.env
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Campaign;
use App\Models\Template;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* Images des signatures (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).
*/
class ImageController extends Controller
{
use ScopedToTenant;
private const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif'];
public function index(): View
{
$dir = $this->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();
}
}
+4
View File
@@ -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'), '/'),
@@ -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
<h1>Images</h1>
<p class="muted" style="margin-top:-8px">
Logos et bannières utilisés dans les signatures, publiés sur <code>{{ config('signature.public_url') }}/img/</code>.
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).
</p>
<div class="card">
<h2>Ajouter une image</h2>
<form method="post" action="{{ route('admin.images.store') }}" enctype="multipart/form-data" class="row" style="align-items:flex-end">
@csrf
<div style="flex:2;min-width:240px">
<label for="image">Fichier (PNG, JPEG, GIF — {{ (int) ($maxKb / 1024) }} Mo max.)</label>
<input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif" required>
</div>
<div style="flex:1;min-width:200px">
<label for="name">Nom (facultatif)</label>
<input type="text" id="name" name="name" maxlength="80" placeholder="ex. logo-cabm">
</div>
<button class="btn" type="submit">Envoyer</button>
</form>
</div>
<div class="card">
<h2>Images en ligne ({{ $images->count() }})</h2>
<table class="list">
<thead><tr><th style="width:150px">Aperçu</th><th>Fichier</th><th>À coller dans un template</th><th>Utilisée par</th><th></th></tr></thead>
<tbody>
@forelse ($images as $img)
@php $snippet = '<img src="'.$tag('site_url').'/img/'.$img['name'].'" alt="" width="'.($img['width'] ?? '').'" style="display:block;border:0">'; @endphp
<tr>
<td><img src="{{ asset('img/'.$img['name']) }}" alt="" style="max-width:140px;max-height:70px;border:1px solid var(--line);background:#fff"></td>
<td>
<a href="{{ $img['url'] }}" target="_blank" rel="noopener">{{ $img['name'] }}</a>
<div class="muted">{{ $img['width'] }}×{{ $img['height'] }} px · {{ $img['kb'] }} Ko
@if ($img['kb'] > 150) <span class="badge warn">lourde</span> @endif</div>
</td>
<td>
<div class="row">
<code style="font-size:12px;word-break:break-all">{{ $snippet }}</code>
<button type="button" class="btn secondary small" data-copy="{{ $snippet }}">Copier</button>
</div>
</td>
<td class="muted">{{ $img['used_in'] ? implode(', ', $img['used_in']) : '—' }}</td>
<td style="text-align:right">
@if ($isAdmin)
<form class="inline" method="post" action="{{ route('admin.images.destroy', $img['name']) }}"
onsubmit="return confirm('Supprimer {{ $img['name'] }} ? Les signatures déjà envoyées qui l\'affichent ne la montreront plus.')">
@csrf @method('DELETE')
<button class="btn danger small" type="submit" @disabled($img['used_in'])>Supprimer</button>
</form>
@endif
</td>
</tr>
@empty
<tr><td colspan="5" class="muted">Aucune image. Commencez par le logo (ex. logo-cabm.png, utilisé par le template par défaut).</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('click', async (e) => {
const text = e.target.dataset && e.target.dataset.copy;
if (!text) return;
try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copié'; }
catch { e.target.textContent = 'Copie impossible'; }
setTimeout(() => { e.target.textContent = 'Copier'; }, 1500);
});
</script>
@endpush
@@ -55,6 +55,7 @@
<nav>
<a href="{{ route('admin.dashboard') }}" @class(['active' => request()->routeIs('admin.dashboard')])>Tableau de bord</a>
<a href="{{ route('admin.templates.index') }}" @class(['active' => request()->routeIs('admin.templates.*')])>Templates</a>
<a href="{{ route('admin.images.index') }}" @class(['active' => request()->routeIs('admin.images.*')])>Images</a>
@if (auth()->user()->hasRole('admin'))
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
@endif
+6
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\Admin\AgentController;
use App\Http\Controllers\Admin\AssignmentController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\ImageController;
use App\Http\Controllers\Admin\SyncController;
use App\Http\Controllers\Admin\TemplateController;
use App\Http\Controllers\Auth\EntraLoginController;
@@ -34,6 +35,10 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na
Route::put('/templates/{template}', [TemplateController::class, 'update'])->name('templates.update');
Route::post('/templates/{template}/versions/{version}/restore', [TemplateController::class, 'restore'])->name('templates.restore');
// Images (logos, bannières) — éditeur : dépôt ; administrateur : suppression
Route::get('/images', [ImageController::class, 'index'])->name('images.index');
Route::post('/images', [ImageController::class, 'store'])->name('images.store');
Route::middleware('role:admin')->group(function () {
Route::post('/templates/{template}/publish', [TemplateController::class, 'publish'])->name('templates.publish');
Route::post('/templates/{template}/default', [TemplateController::class, 'makeDefault'])->name('templates.default');
@@ -45,6 +50,7 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na
Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy');
Route::post('/synchro', SyncController::class)->name('sync');
Route::delete('/images/{name}', [ImageController::class, 'destroy'])->name('images.destroy')->where('name', '[A-Za-z0-9._-]+');
});
// Agents : simulation « qui reçoit quoi » et recherche pour l'aperçu
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Models\Template;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class AdminImagesTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private string $dir;
// PNG 1×1 valide
private const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
protected function setUp(): void
{
parent::setUp();
$this->dir = storage_path('framework/testing/img-'.uniqid());
config(['signature.admin_allowed_cidrs' => [], 'signature.image_path' => $this->dir]);
File::ensureDirectoryExists($this->dir);
$this->tenant = Tenant::create(['name' => 'CABM', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111']);
}
protected function tearDown(): void
{
File::deleteDirectory($this->dir);
parent::tearDown();
}
private function user(string $role): User
{
return User::create(['name' => $role, 'email' => "{$role}@x.fr", 'tenant_id' => $this->tenant->id, 'entra_oid' => '22222222-2222-2222-2222-222222222222', 'role' => $role]);
}
private function png(string $client = 'Logo CABM.png'): UploadedFile
{
$tmp = tempnam(sys_get_temp_dir(), 'png');
file_put_contents($tmp, base64_decode(self::PNG));
return new UploadedFile($tmp, $client, 'image/png', null, true);
}
public function test_editeur_depose_une_image_au_nom_nettoye_sans_ecraser(): void
{
$editor = $this->user('editor');
$this->actingAs($editor)->post('/admin/images', ['image' => $this->png()])->assertRedirect()->assertSessionHasNoErrors();
$this->actingAs($editor)->post('/admin/images', ['image' => $this->png()])->assertRedirect();
$this->assertFileExists($this->dir.'/logo-cabm.png');
$this->assertFileExists($this->dir.'/logo-cabm-2.png');
$this->actingAs($editor)->get('/admin/images')->assertOk()->assertSee('logo-cabm-2.png');
}
public function test_refuse_un_faux_png_et_le_svg(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'x');
file_put_contents($tmp, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
$this->actingAs($this->user('editor'))
->post('/admin/images', ['image' => new UploadedFile($tmp, 'logo.png', 'image/png', null, true)])
->assertSessionHasErrors('image');
$this->assertSame([], File::files($this->dir));
}
public function test_suppression_reservee_admin_et_bloquee_si_utilisee(): void
{
File::ensureDirectoryExists($this->dir);
file_put_contents($this->dir.'/logo.png', base64_decode(self::PNG));
$this->actingAs($this->user('editor'))->delete('/admin/images/logo.png')->assertForbidden();
$admin = User::create(['name' => 'a', 'email' => 'a@x.fr', 'tenant_id' => $this->tenant->id, 'entra_oid' => '33333333-3333-3333-3333-333333333333', 'role' => 'admin']);
$t = Template::create(['tenant_id' => $this->tenant->id, 'name' => 'Défaut', 'draft_html_full' => '<img src="{{site_url}}/img/logo.png">']);
$this->actingAs($admin)->delete('/admin/images/logo.png')->assertSessionHasErrors('image');
$this->assertFileExists($this->dir.'/logo.png');
$t->update(['draft_html_full' => 'texte']);
$this->actingAs($admin)->delete('/admin/images/logo.png')->assertSessionHasNoErrors();
$this->assertFileDoesNotExist($this->dir.'/logo.png');
}
}
+3
View File
@@ -147,6 +147,8 @@ if (( FIRST_INSTALL )); then
echo "DB_PASSWORD=$DB_PASSWORD"
} > "$DEPLOY_ENV"
umask 022
# .env à la racine = fichier lu automatiquement par « docker compose » : les commandes manuelles fonctionnent sans --env-file
ln -sfn deploy.env "$ROOT/.env"
c_ok "deploy.env et docker-compose.override.yml créés (cible NPM : http://$FORWARD_HOST:$FORWARD_PORT)."
# --- Identifiants Entra -----------------------------------------------------
@@ -212,6 +214,7 @@ else
fi
fi
fi
[[ -e "$ROOT/.env" ]] || ln -s deploy.env "$ROOT/.env"
[[ -f "$ADMIN_ALLOW" ]] || printf 'allow 10.0.0.0/8;\nallow 172.16.0.0/12;\nallow 192.168.0.0/16;\n' > "$ADMIN_ALLOW"
# -----------------------------------------------------------------------------