diff --git a/composer.json b/composer.json index b7deb03..0e5eb29 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", + "symfony/http-client": "8.0.*", "symfony/intl": "8.0.*", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", @@ -95,7 +96,6 @@ "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.94", "phpunit/phpunit": "^13.0", - "symfony/browser-kit": "8.0.*", - "symfony/http-client": "8.0.*" + "symfony/browser-kit": "8.0.*" } } diff --git a/composer.lock b/composer.lock index 649a02d..f5fba04 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2dc5db01e7f5d6aecd5956749b21a092", + "content-hash": "b029c1484227c926d39dfd3ae5cb0699", "packages": [ { "name": "api-platform/doctrine-common", @@ -5412,6 +5412,180 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/http-client", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "c7f40f9103233630167c25c9a4570acf805fdade" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade", + "reference": "c7f40f9103233630167c25c9a4570acf805fdade", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:58:02+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.0.8", @@ -11785,180 +11959,6 @@ ], "time": "2026-03-30T15:14:47+00:00" }, - { - "name": "symfony/http-client", - "version": "v8.0.8", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "psr/log": "^1|^2|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<3", - "php-http/discovery": "<1.15" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^5.3.2", - "amphp/http-tunnel": "^2.0", - "guzzlehttp/promises": "^1.4|^2.0", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/cache": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", - "homepage": "https://symfony.com", - "keywords": [ - "http" - ], - "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.8" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:14:47+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.6.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to HTTP clients", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-29T11:18:49+00:00" - }, { "name": "symfony/process", "version": "v8.0.8", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 5689fb5..616caee 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -12,6 +12,9 @@ api_platform: # Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource] # en dehors de Domain/Entity : AuditLogResource, etc. - '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource' + # Entites techniques partagees portant un #[ApiResource] + # (UploadedDocument — infra upload generique ERP-154). + - '%kernel.project_dir%/src/Shared/Domain/Entity' formats: jsonld: ['application/ld+json'] json: ['application/json'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index d8a832a..f2cb924 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -50,6 +50,16 @@ doctrine: # Shared sans importer la classe concrete du module Catalog (regle n°1). App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category mappings: + # Mapping des entites techniques partagees (src/Shared/Domain/Entity). + # Premier occupant : UploadedDocument (infra upload generique ERP-154). + # Necessaire car les entites Shared ne sont pas couvertes par + # l'auto_mapping (qui ne cible que les bundles). + Shared: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Shared/Domain/Entity' + prefix: 'App\Shared\Domain\Entity' + alias: Shared Core: type: attribute is_bundle: false diff --git a/config/version.yaml b/config/version.yaml index afd1d9d..0cbc294 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.123' + app.version: '0.1.124' diff --git a/migrations/Version20260615130000.php b/migrations/Version20260615130000.php new file mode 100644 index 0000000..794c5a9 --- /dev/null +++ b/migrations/Version20260615130000.php @@ -0,0 +1,86 @@ +addSql(<<<'SQL' + CREATE TABLE uploaded_document ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + original_filename VARCHAR(255) NOT NULL, + stored_path VARCHAR(512) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + size_bytes INT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_uploaded_document_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + // Postgres n'indexe pas automatiquement les colonnes de FK. + $this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)'); + // Recherche d'integrite / future deduplication par empreinte sha256. + $this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)'); + + $this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/.pdf) — nom genere aleatoirement, jamais le nom client.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS uploaded_document'); + } +} diff --git a/src/Shared/Domain/Entity/UploadedDocument.php b/src/Shared/Domain/Entity/UploadedDocument.php new file mode 100644 index 0000000..e1151cd --- /dev/null +++ b/src/Shared/Domain/Entity/UploadedDocument.php @@ -0,0 +1,166 @@ + 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; + } +} diff --git a/src/Shared/Domain/Exception/FileTooLargeException.php b/src/Shared/Domain/Exception/FileTooLargeException.php new file mode 100644 index 0000000..d77c4a4 --- /dev/null +++ b/src/Shared/Domain/Exception/FileTooLargeException.php @@ -0,0 +1,21 @@ + $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), + )); + } +} diff --git a/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php b/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php new file mode 100644 index 0000000..818aeb4 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php @@ -0,0 +1,81 @@ + 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 + */ +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; + } + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index a6f6dee..05298a7 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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/.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).", diff --git a/src/Shared/Infrastructure/Upload/FileUploader.php b/src/Shared/Infrastructure/Upload/FileUploader.php new file mode 100644 index 0000000..a7f61f5 --- /dev/null +++ b/src/Shared/Infrastructure/Upload/FileUploader.php @@ -0,0 +1,128 @@ + + */ + 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); + } + } +} diff --git a/tests/Shared/Api/UploadedDocumentApiTest.php b/tests/Shared/Api/UploadedDocumentApiTest.php new file mode 100644 index 0000000..30bd5b8 --- /dev/null +++ b/tests/Shared/Api/UploadedDocumentApiTest.php @@ -0,0 +1,132 @@ + 201, IRI renvoyee, ligne persistee, + * checksum sha256 calcule cote serveur ; + * - POST d'un MIME hors whitelist (text/plain) -> 422 ; + * - POST sans fichier -> 422 ; + * - POST anonyme -> 401 (acces /api protege globalement). + * + * @internal + */ +final class UploadedDocumentApiTest extends AbstractApiTestCase +{ + private const string ENDPOINT = '/api/uploaded_documents'; + + /** @var list */ + private array $tempFiles = []; + + protected function tearDown(): void + { + foreach ($this->tempFiles as $path) { + if (is_file($path)) { + @unlink($path); + } + } + parent::tearDown(); + } + + public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $content = $this->minimalPdf(); + $file = $this->makeUploadedFile($content, 'facture.pdf'); + + $response = $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(201); + + $data = $response->toArray(); + + self::assertArrayHasKey('@id', $data); + self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']); + self::assertSame('facture.pdf', $data['originalFilename']); + self::assertSame('application/pdf', $data['mimeType']); + self::assertSame(\strlen($content), $data['sizeBytes']); + self::assertSame(hash('sha256', $content), $data['checksum']); + self::assertSame(64, \strlen($data['checksum'])); + + // La ligne est bien persistee et relisible via le repository. + $id = $data['id']; + $document = $this->getEm()->getRepository(UploadedDocument::class)->find($id); + self::assertInstanceOf(UploadedDocument::class, $document); + self::assertSame(hash('sha256', $content), $document->getChecksum()); + } + + public function testUploadDisallowedMimeTypeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $file = $this->makeUploadedFile('just some plain text content', 'note.txt'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testUploadWithoutFileReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => []], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testUploadAnonymousIsRejected(): void + { + $client = self::createClient(); + $file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Cree un UploadedFile en mode test (move() autorise hors contexte HTTP). + */ + private function makeUploadedFile(string $content, string $clientName): UploadedFile + { + $path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4)); + file_put_contents($path, $content); + $this->tempFiles[] = $path; + + return new UploadedFile($path, $clientName, null, null, true); + } + + /** + * Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`). + */ + private function minimalPdf(): string + { + return "%PDF-1.4\n" + ."1 0 obj<>endobj\n" + ."2 0 obj<>endobj\n" + ."3 0 obj<>endobj\n" + ."trailer<>\n" + ."%%EOF\n"; + } +} diff --git a/tests/Shared/Infrastructure/Upload/FileUploaderTest.php b/tests/Shared/Infrastructure/Upload/FileUploaderTest.php new file mode 100644 index 0000000..90c8f6c --- /dev/null +++ b/tests/Shared/Infrastructure/Upload/FileUploaderTest.php @@ -0,0 +1,161 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + $this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + // Nettoyage des fichiers sources et de l'arborescence de destination. + foreach ($this->tempFiles as $path) { + if (is_file($path)) { + @unlink($path); + } + } + $this->removeDirectory($this->uploadBaseDir); + } + + public function testRejectsMimeTypeOutsideWhitelist(): void + { + $uploader = $this->createUploader(); + $file = $this->makeUploadedFile('hello world plain text', 'note.txt'); + + $this->expectException(UnsupportedMimeTypeException::class); + + $uploader->upload($file); + } + + public function testRejectsFileLargerThanMaxSize(): void + { + $uploader = $this->createUploader(); + + // Contenu PDF valide mais artificiellement gonfle au-dela de la borne. + $content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1); + $file = $this->makeUploadedFile($content, 'huge.pdf'); + + $this->expectException(FileTooLargeException::class); + + $uploader->upload($file); + } + + public function testStoresPdfAndComputesSha256Checksum(): void + { + $content = $this->minimalPdf(); + $clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00')); + $uploader = $this->createUploader($clock); + $file = $this->makeUploadedFile($content, 'facture.pdf'); + + $document = $uploader->upload($file); + + self::assertSame('facture.pdf', $document->getOriginalFilename()); + self::assertSame('application/pdf', $document->getMimeType()); + self::assertSame(\strlen($content), $document->getSizeBytes()); + self::assertSame(hash('sha256', $content), $document->getChecksum()); + self::assertSame(64, \strlen($document->getChecksum())); + + // Chemin relatif date selon l'horloge injectee (2026/06). + self::assertStringStartsWith('2026/06/', $document->getStoredPath()); + self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath()); + + // Le fichier ecrit a bien le contenu d'origine (checksum coherent). + self::assertSame( + $document->getChecksum(), + hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()), + ); + } + + public function testRemoveDeletesStoredFile(): void + { + $uploader = $this->createUploader(); + $file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf'); + $document = $uploader->upload($file); + $storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath(); + self::assertFileExists($storedPath); + + // Compensation : remove() efface le fichier physique... + $uploader->remove($document); + self::assertFileDoesNotExist($storedPath); + + // ...et reste silencieux si on le rappelle alors que le fichier a disparu. + $uploader->remove($document); + self::assertFileDoesNotExist($storedPath); + } + + private function createUploader(?MockClock $clock = null): FileUploader + { + return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock()); + } + + /** + * Cree un UploadedFile en mode test (move() autorise hors contexte HTTP). + */ + private function makeUploadedFile(string $content, string $clientName): UploadedFile + { + $path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4)); + file_put_contents($path, $content); + $this->tempFiles[] = $path; + + // Le 5e argument `test: true` court-circuite move_uploaded_file(). + return new UploadedFile($path, $clientName, null, null, true); + } + + /** + * Contenu PDF minimal valide — l'entete `%PDF-1.4` suffit a faire detecter + * `application/pdf` par finfo (getMimeType server-side). + */ + private function minimalPdf(): string + { + return "%PDF-1.4\n" + ."1 0 obj<>endobj\n" + ."2 0 obj<>endobj\n" + ."3 0 obj<>endobj\n" + ."trailer<>\n" + ."%%EOF\n"; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = scandir($dir) ?: []; + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + is_dir($path) ? $this->removeDirectory($path) : @unlink($path); + } + @rmdir($dir); + } +}