1e783bd753
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
167 lines
5.5 KiB
PHP
167 lines
5.5 KiB
PHP
<?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. Protege par IS_AUTHENTICATED_FULLY uniquement
|
|
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
|
|
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
|
|
* cloisonnement d'acces (qui peut voir quel document) est volontairement
|
|
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
|
|
* document via sa propre ressource cloisonnee plutot que via cet endpoint
|
|
* technique. Ne renvoie que des metadonnees (jamais le binaire).
|
|
*
|
|
* 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;
|
|
}
|
|
}
|