From ab835e5c3d7cdb7653ffda27c57d08b2fd9191e5 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 24 Mar 2026 16:08:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20=C3=A0=20l'enseignant=20de?= =?UTF-8?q?=20r=C3=A9diger=20avec=20un=20=C3=A9diteur=20riche=20et=20joind?= =?UTF-8?q?re=20des=20fichiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les enseignants avaient besoin de consignes plus claires pour les élèves : le champ description en texte brut ne permettait ni mise en forme ni partage de documents. Cette limitation obligeait à décrire verbalement les ressources au lieu de les joindre directement. L'éditeur WYSIWYG (TipTap) remplace le textarea avec gras, italique, listes et liens. Le contenu HTML est sanitisé côté backend via symfony/html-sanitizer pour prévenir les injections XSS. Les pièces jointes (PDF, JPEG, PNG, max 10 Mo) sont uploadées via une API dédiée avec validation MIME côté domaine et protection path-traversal sur le téléchargement. Les descriptions en texte brut existantes restent lisibles sans migration de données. --- backend/composer.json | 1 + backend/composer.lock | 256 ++++++++- backend/config/packages/html_sanitizer.yaml | 13 + backend/config/services.yaml | 7 + .../CreateHomework/CreateHomeworkHandler.php | 8 +- .../UpdateHomework/UpdateHomeworkHandler.php | 8 +- .../UploadHomeworkAttachmentHandler.php | 2 +- .../Application/Port/HtmlSanitizer.php | 10 + .../HomeworkAttachmentRepository.php | 2 + .../HomeworkAttachmentController.php | 207 +++++++ .../Controller/ParentHomeworkController.php | 5 +- .../Controller/StudentHomeworkController.php | 5 +- .../DoctrineHomeworkAttachmentRepository.php | 12 + .../InMemoryHomeworkAttachmentRepository.php | 17 + .../Service/HomeworkHtmlSanitizer.php | 23 + .../CreateHomeworkHandlerTest.php | 96 ++++ .../UpdateHomeworkHandlerTest.php | 88 ++- .../Service/HomeworkHtmlSanitizerTest.php | 158 ++++++ .../e2e/homework-richtext-attachments.spec.ts | 467 +++++++++++++++ frontend/e2e/homework.spec.ts | 8 +- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 535 ++++++++++++++++++ .../molecules/FileUpload/FileUpload.svelte | 276 +++++++++ .../RichTextEditor/RichTextEditor.svelte | 300 ++++++++++ .../StudentHomework/HomeworkDetail.svelte | 31 +- .../dashboard/teacher/homework/+page.svelte | 147 ++++- 26 files changed, 2655 insertions(+), 33 deletions(-) create mode 100644 backend/config/packages/html_sanitizer.yaml create mode 100644 backend/src/Scolarite/Application/Port/HtmlSanitizer.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Controller/HomeworkAttachmentController.php create mode 100644 backend/src/Scolarite/Infrastructure/Service/HomeworkHtmlSanitizer.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/Service/HomeworkHtmlSanitizerTest.php create mode 100644 frontend/e2e/homework-richtext-attachments.spec.ts create mode 100644 frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte create mode 100644 frontend/src/lib/components/molecules/RichTextEditor/RichTextEditor.svelte diff --git a/backend/composer.json b/backend/composer.json index 409914d..261dbfa 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -28,6 +28,7 @@ "symfony/dotenv": "^8.0", "symfony/flex": "^2", "symfony/framework-bundle": "^8.0", + "symfony/html-sanitizer": "8.0.*", "symfony/http-client": "8.0.*", "symfony/lock": "8.0.*", "symfony/mailer": "8.0.*", diff --git a/backend/composer.lock b/backend/composer.lock index fb21f74..57b067c 100644 --- a/backend/composer.lock +++ b/backend/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": "cf5f7c77977031afccfa7da74ed52205", + "content-hash": "92b9472c96a59c314d96372c4094f185", "packages": [ { "name": "api-platform/core", @@ -1785,6 +1785,188 @@ ], "time": "2025-10-17T11:30:53+00:00" }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, { "name": "lexik/jwt-authentication-bundle", "version": "v3.2.0", @@ -4841,6 +5023,78 @@ ], "time": "2026-01-27T09:06:10+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2", + "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7" + }, + "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-06T13:17:40+00:00" + }, { "name": "symfony/http-client", "version": "v8.0.5", diff --git a/backend/config/packages/html_sanitizer.yaml b/backend/config/packages/html_sanitizer.yaml new file mode 100644 index 0000000..7b1efb3 --- /dev/null +++ b/backend/config/packages/html_sanitizer.yaml @@ -0,0 +1,13 @@ +framework: + html_sanitizer: + sanitizers: + homework_sanitizer: + allow_elements: + p: [] + br: [] + strong: [] + em: [] + ul: [] + ol: [] + li: [] + a: ['href', 'target', 'rel'] diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 5a6c87b..b316339 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -222,6 +222,13 @@ services: App\Scolarite\Domain\Service\HomeworkDuplicator: autowire: true + App\Scolarite\Application\Port\HtmlSanitizer: + alias: App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer + + App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer: + arguments: + $homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer' + App\Scolarite\Application\Port\FileStorage: alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage diff --git a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php index ae9d29c..11875bf 100644 --- a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php +++ b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php @@ -10,6 +10,7 @@ use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\EnseignantAffectationChecker; use App\Scolarite\Application\Port\HomeworkRulesChecker; +use App\Scolarite\Application\Port\HtmlSanitizer; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException; use App\Scolarite\Domain\Model\Homework\Homework; @@ -29,6 +30,7 @@ final readonly class CreateHomeworkHandler private CurrentCalendarProvider $calendarProvider, private DueDateValidator $dueDateValidator, private HomeworkRulesChecker $rulesChecker, + private HtmlSanitizer $htmlSanitizer, private Clock $clock, ) { } @@ -63,13 +65,17 @@ final readonly class CreateHomeworkHandler throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray()); } + $description = $command->description !== null + ? $this->htmlSanitizer->sanitize($command->description) + : null; + $homework = Homework::creer( tenantId: $tenantId, classId: $classId, subjectId: $subjectId, teacherId: $teacherId, title: $command->title, - description: $command->description, + description: $description, dueDate: $dueDate, now: $now, ); diff --git a/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandler.php b/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandler.php index 02bb5e2..3f9ab7e 100644 --- a/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandler.php +++ b/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandler.php @@ -6,6 +6,7 @@ namespace App\Scolarite\Application\Command\UpdateHomework; use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Application\Port\CurrentCalendarProvider; +use App\Scolarite\Application\Port\HtmlSanitizer; use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException; use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\HomeworkId; @@ -23,6 +24,7 @@ final readonly class UpdateHomeworkHandler private HomeworkRepository $homeworkRepository, private CurrentCalendarProvider $calendarProvider, private DueDateValidator $dueDateValidator, + private HtmlSanitizer $htmlSanitizer, private Clock $clock, ) { } @@ -44,9 +46,13 @@ final readonly class UpdateHomeworkHandler $dueDate = new DateTimeImmutable($command->dueDate); $this->dueDateValidator->valider($dueDate, $now, $calendar); + $description = $command->description !== null + ? $this->htmlSanitizer->sanitize($command->description) + : null; + $homework->modifier( title: $command->title, - description: $command->description, + description: $description, dueDate: $dueDate, now: $now, ); diff --git a/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentHandler.php b/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentHandler.php index ba0b3a9..8548185 100644 --- a/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentHandler.php +++ b/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentHandler.php @@ -37,7 +37,7 @@ final readonly class UploadHomeworkAttachmentHandler $this->homeworkRepository->get($homeworkId, $tenantId); $attachmentId = HomeworkAttachmentId::generate(); - $storagePath = sprintf('homework/%s/%s/%s', $command->tenantId, $command->homeworkId, $command->filename); + $storagePath = sprintf('homework/%s/%s/%s/%s', $command->tenantId, $command->homeworkId, (string) $attachmentId, $command->filename); $content = file_get_contents($command->tempFilePath); diff --git a/backend/src/Scolarite/Application/Port/HtmlSanitizer.php b/backend/src/Scolarite/Application/Port/HtmlSanitizer.php new file mode 100644 index 0000000..cdf6ad2 --- /dev/null +++ b/backend/src/Scolarite/Application/Port/HtmlSanitizer.php @@ -0,0 +1,10 @@ +getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + return new JsonResponse(array_map( + static fn (HomeworkAttachment $a): array => [ + 'id' => (string) $a->id, + 'filename' => $a->filename, + 'fileSize' => $a->fileSize, + 'mimeType' => $a->mimeType, + ], + $attachments, + )); + } + + #[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_upload', methods: ['POST'])] + public function upload(string $id, Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Seul le propriétaire peut ajouter des pièces jointes.'); + } + + $file = $request->files->get('file'); + + if ($file === null) { + throw new BadRequestHttpException('Aucun fichier envoyé.'); + } + + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ + $originalName = $file->getClientOriginalName(); + $mimeType = $file->getMimeType() ?? $file->getClientMimeType() ?? ''; + $fileSize = $file->getSize(); + + try { + $attachment = ($this->uploadHandler)(new UploadHomeworkAttachmentCommand( + tenantId: $user->tenantId(), + homeworkId: $id, + filename: $originalName, + mimeType: $mimeType, + fileSize: (int) $fileSize, + tempFilePath: $file->getPathname(), + )); + + $this->attachmentRepository->save($homework->id, $attachment); + } catch (PieceJointeInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + return new JsonResponse([ + 'id' => (string) $attachment->id, + 'filename' => $attachment->filename, + 'fileSize' => $attachment->fileSize, + 'mimeType' => $attachment->mimeType, + ], Response::HTTP_CREATED); + } + + #[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])] + public function download(string $id, string $attachmentId): BinaryFileResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + foreach ($attachments as $attachment) { + if ((string) $attachment->id === $attachmentId) { + $fullPath = $this->storageDir . '/' . $attachment->filePath; + $realPath = realpath($fullPath); + $realStorageDir = realpath($this->storageDir); + + if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) { + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + $response = new BinaryFileResponse($realPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + $attachment->filename, + ); + + return $response; + } + } + + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + #[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_delete', methods: ['DELETE'])] + public function delete(string $id, string $attachmentId): Response + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Seul le propriétaire peut supprimer des pièces jointes.'); + } + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + foreach ($attachments as $attachment) { + if ((string) $attachment->id === $attachmentId) { + $this->fileStorage->delete($attachment->filePath); + $this->attachmentRepository->delete($homework->id, $attachment); + + return new Response(status: Response::HTTP_NO_CONTENT); + } + } + + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php index 0226288..c00bce2 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentHomeworkController.php @@ -45,7 +45,7 @@ final readonly class ParentHomeworkController private GetChildrenHomeworkDetailHandler $detailHandler, private HomeworkRepository $homeworkRepository, private HomeworkAttachmentRepository $attachmentRepository, - #[Autowire('%kernel.project_dir%/var/uploads')] + #[Autowire('%kernel.project_dir%/var/storage')] private string $uploadsDir, ) { } @@ -138,7 +138,8 @@ final readonly class ParentHomeworkController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $realPath = realpath($attachment->filePath); + $fullPath = $this->uploadsDir . '/' . $attachment->filePath; + $realPath = realpath($fullPath); $realUploadsDir = realpath($this->uploadsDir); if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php index 2bbd470..8ccc3ac 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php @@ -44,7 +44,7 @@ final readonly class StudentHomeworkController private HomeworkAttachmentRepository $attachmentRepository, private ScheduleDisplayReader $displayReader, private StudentClassReader $studentClassReader, - #[Autowire('%kernel.project_dir%/var/uploads')] + #[Autowire('%kernel.project_dir%/var/storage')] private string $uploadsDir, ) { } @@ -115,7 +115,8 @@ final readonly class StudentHomeworkController foreach ($attachments as $attachment) { if ((string) $attachment->id === $attachmentId) { - $realPath = realpath($attachment->filePath); + $fullPath = $this->uploadsDir . '/' . $attachment->filePath; + $realPath = realpath($fullPath); $realUploadsDir = realpath($this->uploadsDir); if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php index a552c71..fbee12d 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php @@ -80,6 +80,18 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt ); } + #[Override] + public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void + { + $this->connection->executeStatement( + 'DELETE FROM homework_attachments WHERE id = :id AND homework_id = :homework_id', + [ + 'id' => (string) $attachment->id, + 'homework_id' => (string) $homeworkId, + ], + ); + } + /** @param array $row */ private function hydrate(array $row): HomeworkAttachment { diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php index 00f7aea..b5bd9ea 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php @@ -9,7 +9,9 @@ use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use function array_fill_keys; +use function array_filter; use function array_map; +use function array_values; use Override; @@ -42,4 +44,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe { $this->byHomeworkId[(string) $homeworkId][] = $attachment; } + + #[Override] + public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void + { + $key = (string) $homeworkId; + + if (!isset($this->byHomeworkId[$key])) { + return; + } + + $this->byHomeworkId[$key] = array_values(array_filter( + $this->byHomeworkId[$key], + static fn (HomeworkAttachment $a): bool => (string) $a->id !== (string) $attachment->id, + )); + } } diff --git a/backend/src/Scolarite/Infrastructure/Service/HomeworkHtmlSanitizer.php b/backend/src/Scolarite/Infrastructure/Service/HomeworkHtmlSanitizer.php new file mode 100644 index 0000000..eb3a5b8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/HomeworkHtmlSanitizer.php @@ -0,0 +1,23 @@ +homeworkSanitizer->sanitize($html); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php index 98b6e63..8c7ae33 100644 --- a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php @@ -15,6 +15,7 @@ use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\EnseignantAffectationChecker; use App\Scolarite\Application\Port\HomeworkRulesChecker; use App\Scolarite\Application\Port\HomeworkRulesCheckResult; +use App\Scolarite\Application\Port\HtmlSanitizer; use App\Scolarite\Application\Port\RuleWarning; use App\Scolarite\Domain\Exception\DateEcheanceInvalideException; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; @@ -110,6 +111,46 @@ final class CreateHomeworkHandlerTest extends TestCase self::assertNull($homework->description); } + #[Test] + public function itSanitizesHtmlDescription(): void + { + $sanitizer = new class implements HtmlSanitizer { + public function sanitize(string $html): string + { + return strip_tags($html, '