feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.
## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.
## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).
`make test` vert (701 tests), `php-cs-fixer` propre.
## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).
Ticket ERP-154.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
|
||||
*
|
||||
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
|
||||
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
|
||||
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
|
||||
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
|
||||
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
|
||||
* en JSON-LD (avec son @id / IRI).
|
||||
*
|
||||
* Mapping des erreurs :
|
||||
* - fichier absent -> 422 ;
|
||||
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
|
||||
*
|
||||
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
|
||||
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
|
||||
*
|
||||
* @implements ProcessorInterface<mixed, mixed>
|
||||
*/
|
||||
final class UploadedDocumentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly FileUploader $fileUploader,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $this->fileUploader->upload($file);
|
||||
} catch (FileUploadException $e) {
|
||||
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
|
||||
// message metier explicite porte par l'exception.
|
||||
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof UserInterface) {
|
||||
$document->setCreatedBy($user);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
|
||||
} catch (Throwable $e) {
|
||||
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
|
||||
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
|
||||
// uploaded_document correspondante, puis on relaie l'erreur.
|
||||
$this->fileUploader->remove($document);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'uploaded_document' => [
|
||||
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
|
||||
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
|
||||
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
|
||||
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
|
||||
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
|
||||
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
|
||||
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
|
||||
],
|
||||
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Upload;
|
||||
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Service generique de televersement de fichiers (infra Shared, ERP-154).
|
||||
*
|
||||
* Responsabilites :
|
||||
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
|
||||
* sur le contenu reel) — JAMAIS `getClientMimeType()`, spoofable par le
|
||||
* client (regle backend.md « Upload de fichiers »).
|
||||
* - Whitelist MIME explicite (PDF + images courantes).
|
||||
* - Bornage de la taille (MAX_SIZE_BYTES).
|
||||
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
|
||||
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
|
||||
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
|
||||
*
|
||||
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
|
||||
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
|
||||
* UploadedDocumentProcessor.
|
||||
*/
|
||||
final class FileUploader
|
||||
{
|
||||
/**
|
||||
* Types MIME autorises (detectes server-side) : PDF + images courantes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/**
|
||||
* Taille maximale autorisee : 10 Mo.
|
||||
*/
|
||||
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
// Racine de stockage des fichiers televerses (hors web root, sous var/).
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private readonly string $uploadBaseDir,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
|
||||
* un UploadedDocument NON persiste (le caller le persiste).
|
||||
*
|
||||
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
|
||||
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
|
||||
*/
|
||||
public function upload(UploadedFile $file): UploadedDocument
|
||||
{
|
||||
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
|
||||
// declare par le client.
|
||||
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
|
||||
}
|
||||
|
||||
// getSize() peut renvoyer false si le fichier est illisible.
|
||||
$size = $file->getSize();
|
||||
if (false === $size || $size > self::MAX_SIZE_BYTES) {
|
||||
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
|
||||
}
|
||||
|
||||
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
|
||||
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
|
||||
// on echoue proprement plutot que de propager un TypeError opaque au
|
||||
// constructeur (parametre $checksum type string).
|
||||
$checksum = hash_file('sha256', $file->getPathname());
|
||||
if (false === $checksum) {
|
||||
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$relativeDir = $now->format('Y').'/'.$now->format('m');
|
||||
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
|
||||
|
||||
// Nom de stockage genere aleatoirement : evite les collisions et toute
|
||||
// injection via le nom client. Extension deduite du MIME.
|
||||
$extension = $file->guessExtension() ?: 'bin';
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
|
||||
|
||||
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
|
||||
// borne a la longueur de colonne (255).
|
||||
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
|
||||
|
||||
$file->move($targetDir, $storedName);
|
||||
|
||||
return new UploadedDocument(
|
||||
originalFilename: $originalFilename,
|
||||
storedPath: $relativeDir.'/'.$storedName,
|
||||
mimeType: $mimeType,
|
||||
sizeBytes: $size,
|
||||
checksum: $checksum,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le fichier physique d'un document (compensation lorsque la
|
||||
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
|
||||
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
|
||||
* references en base.
|
||||
*/
|
||||
public function remove(UploadedDocument $document): void
|
||||
{
|
||||
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user