feat(shared) : infra upload générique (ERP-154)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m40s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m22s

Pose une infra d'upload de fichiers générique et réutilisable dans Shared
(spec M4 § 2.7), sans toucher au module Transport.

- Table uploaded_document (migration racine DoctrineMigrations) : fichier
  téléversé immuable (PDF / images), checksum sha256, created_at/created_by.
- Service Shared\Infrastructure\Upload\FileUploader : validation MIME
  server-side via getMimeType (jamais getClientMimeType), whitelist explicite
  (PDF + images), bornage taille, checksum sha256, écriture var/uploads/{yyyy}/{mm}/.
- Endpoint POST /api/uploaded_documents (multipart, deserialize:false) +
  UploadedDocumentProcessor -> renvoie l'IRI ; MIME hors whitelist -> 422.
- COMMENT ON COLUMN sur toutes les colonnes + bloc dans ColumnCommentsCatalog.
- Mapping Doctrine Shared + path API Platform Shared.
- Tests : FileUploader (unit) + endpoint (fonctionnel, 422 / IRI / checksum).
This commit is contained in:
Matthieu
2026-06-15 16:08:24 +02:00
parent 6a83adc00a
commit b989c33cc4
12 changed files with 783 additions and 0 deletions
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
*
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
* processor depuis l'utilisateur authentifie (null hors HTTP).
*
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
*
* Operations API :
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
* whitelist -> 422.
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
* l'IRI renvoyee par le Post. Securisee par l'authentification globale /api.
*
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
* s'applique donc pas ici.
*/
#[ORM\Entity]
#[ORM\Table(name: 'uploaded_document')]
#[ApiResource(
operations: [
new Get(
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
// Entree multipart : le binaire arrive en multipart/form-data sous
// le champ « file ». Sans cet inputFormats, API Platform rejette la
// requete en 415.
inputFormats: ['multipart' => ['multipart/form-data']],
// Le fichier n'est pas deserialisable dans l'entite : le processor
// lit le binaire de la requete. La validation est portee par le
// FileUploader (MIME server-side, taille), pas par les contraintes.
deserialize: false,
validate: false,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
processor: UploadedDocumentProcessor::class,
),
],
normalizationContext: ['groups' => ['uploaded_document:read']],
)]
class UploadedDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['uploaded_document:read'])]
private ?int $id = null;
#[ORM\Column(name: 'original_filename', length: 255)]
#[Groups(['uploaded_document:read'])]
private string $originalFilename;
#[ORM\Column(name: 'stored_path', length: 512)]
#[Groups(['uploaded_document:read'])]
private string $storedPath;
#[ORM\Column(name: 'mime_type', length: 100)]
#[Groups(['uploaded_document:read'])]
private string $mimeType;
#[ORM\Column(name: 'size_bytes', type: 'integer')]
#[Groups(['uploaded_document:read'])]
private int $sizeBytes;
#[ORM\Column(name: 'checksum', length: 64)]
#[Groups(['uploaded_document:read'])]
private string $checksum;
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['uploaded_document:read'])]
private DateTimeImmutable $createdAt;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?UserInterface $createdBy = null;
public function __construct(
string $originalFilename,
string $storedPath,
string $mimeType,
int $sizeBytes,
string $checksum,
DateTimeImmutable $createdAt,
) {
$this->originalFilename = $originalFilename;
$this->storedPath = $storedPath;
$this->mimeType = $mimeType;
$this->sizeBytes = $sizeBytes;
$this->checksum = $checksum;
$this->createdAt = $createdAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getOriginalFilename(): string
{
return $this->originalFilename;
}
public function getStoredPath(): string
{
return $this->storedPath;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function getSizeBytes(): int
{
return $this->sizeBytes;
}
public function getChecksum(): string
{
return $this->checksum;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le fichier televerse depasse la taille maximale autorisee
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
*/
final class FileTooLargeException extends FileUploadException
{
public function __construct(int $size, int $maxSize)
{
parent::__construct(sprintf(
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
$size,
$maxSize,
));
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use RuntimeException;
/**
* Exception de base des erreurs de televersement (FileUploader).
*
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
* qui la traduit en reponse HTTP 422.
*/
class FileUploadException extends RuntimeException {}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
*/
final class UnsupportedMimeTypeException extends FileUploadException
{
/**
* @param list<string> $allowed Types MIME autorises
*/
public function __construct(string $mimeType, array $allowed)
{
parent::__construct(sprintf(
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
$mimeType,
implode(', ', $allowed),
));
}
}
@@ -0,0 +1,68 @@
<?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;
/**
* 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.
*
* @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);
}
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
}
}
@@ -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,107 @@
<?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\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.
$checksum = hash_file('sha256', $file->getPathname());
$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,
);
}
}