feat: Permettre à l'enseignant de rédiger avec un éditeur riche et joindre des fichiers

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.
This commit is contained in:
2026-03-24 16:08:23 +01:00
parent 93baeb1eaa
commit ab835e5c3d
26 changed files with 2655 additions and 33 deletions

View File

@@ -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.*",

256
backend/composer.lock generated
View File

@@ -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",

View File

@@ -0,0 +1,13 @@
framework:
html_sanitizer:
sanitizers:
homework_sanitizer:
allow_elements:
p: []
br: []
strong: []
em: []
ul: []
ol: []
li: []
a: ['href', 'target', 'rel']

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
interface HtmlSanitizer
{
public function sanitize(string $html): string;
}

View File

@@ -18,4 +18,6 @@ interface HomeworkAttachmentRepository
public function hasAttachments(HomeworkId ...$homeworkIds): array;
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentCommand;
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function realpath;
use function str_starts_with;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final readonly class HomeworkAttachmentController
{
public function __construct(
private Security $security,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private UploadHomeworkAttachmentHandler $uploadHandler,
private FileStorage $fileStorage,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_list', methods: ['GET'])]
public function list(string $id): 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('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;
}
}

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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<string, mixed> $row */
private function hydrate(array $row): HomeworkAttachment
{

View File

@@ -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,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\HtmlSanitizer;
use Override;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
final readonly class HomeworkHtmlSanitizer implements HtmlSanitizer
{
public function __construct(
private HtmlSanitizerInterface $homeworkSanitizer,
) {
}
#[Override]
public function sanitize(string $html): string
{
return $this->homeworkSanitizer->sanitize($html);
}
}

View File

@@ -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, '<p><strong><em><ul><ol><li><a>');
}
};
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
$command = $this->createCommand(description: '<p>Texte <strong>gras</strong></p><script>alert("xss")</script>');
$homework = $handler($command);
self::assertSame('<p>Texte <strong>gras</strong></p>alert("xss")', $homework->description);
}
#[Test]
public function itDoesNotSanitizeNullDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public bool $called = false;
public function sanitize(string $html): string
{
$this->called = true;
return $html;
}
};
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
$command = $this->createCommand(description: null);
$handler($command);
self::assertFalse($sanitizer->called);
}
#[Test]
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
{
@@ -269,12 +310,67 @@ final class CreateHomeworkHandlerTest extends TestCase
}
};
$htmlSanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return $html;
}
};
return new CreateHomeworkHandler(
$this->homeworkRepository,
$affectationChecker,
$calendarProvider,
new DueDateValidator(),
$rulesChecker,
$htmlSanitizer,
$this->clock,
);
}
private function createHandlerWithSanitizer(bool $affecte, HtmlSanitizer $htmlSanitizer, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
{
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
public function __construct(private readonly bool $affecte)
{
}
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
{
return $this->affecte;
}
};
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
zone: null,
entries: [],
);
}
};
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
public function __construct(private readonly HomeworkRulesCheckResult $result)
{
}
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
{
return $this->result;
}
};
return new CreateHomeworkHandler(
$this->homeworkRepository,
$affectationChecker,
$calendarProvider,
new DueDateValidator(),
$rulesChecker,
$htmlSanitizer,
$this->clock,
);
}

View File

@@ -12,6 +12,7 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\HtmlSanitizer;
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
@@ -172,6 +173,60 @@ final class UpdateHomeworkHandlerTest extends TestCase
self::assertSame('Exercices sans description', $homework->title);
}
#[Test]
public function itSanitizesHtmlDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
}
};
$handler = $this->createHandlerWithSanitizer($sanitizer);
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->existingHomeworkId,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Test sanitize',
description: '<p>Texte</p><script>alert("xss")</script>',
dueDate: '2026-04-20',
);
$homework = $handler($command);
self::assertSame('<p>Texte</p>alert("xss")', $homework->description);
}
#[Test]
public function itDoesNotSanitizeNullDescription(): void
{
$sanitizer = new class implements HtmlSanitizer {
public bool $called = false;
public function sanitize(string $html): string
{
$this->called = true;
return $html;
}
};
$handler = $this->createHandlerWithSanitizer($sanitizer);
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->existingHomeworkId,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Test null desc',
description: null,
dueDate: '2026-04-20',
);
$handler($command);
self::assertFalse($sanitizer->called);
}
#[Test]
public function itThrowsWhenNotOwner(): void
{
@@ -206,7 +261,7 @@ final class UpdateHomeworkHandlerTest extends TestCase
$this->homeworkRepository->save($homework);
}
private function createHandler(): UpdateHomeworkHandler
private function createHandlerWithSanitizer(HtmlSanitizer $htmlSanitizer): UpdateHomeworkHandler
{
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
@@ -224,6 +279,37 @@ final class UpdateHomeworkHandlerTest extends TestCase
$this->homeworkRepository,
$calendarProvider,
new DueDateValidator(),
$htmlSanitizer,
$this->clock,
);
}
private function createHandler(): UpdateHomeworkHandler
{
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
zone: null,
entries: [],
);
}
};
$htmlSanitizer = new class implements HtmlSanitizer {
public function sanitize(string $html): string
{
return $html;
}
};
return new UpdateHomeworkHandler(
$this->homeworkRepository,
$calendarProvider,
new DueDateValidator(),
$htmlSanitizer,
$this->clock,
);
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
use App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
final class HomeworkHtmlSanitizerTest extends TestCase
{
private HomeworkHtmlSanitizer $sanitizer;
protected function setUp(): void
{
$config = (new HtmlSanitizerConfig())
->allowElement('p')
->allowElement('br')
->allowElement('strong')
->allowElement('em')
->allowElement('ul')
->allowElement('ol')
->allowElement('li')
->allowElement('a', ['href', 'target', 'rel']);
$this->sanitizer = new HomeworkHtmlSanitizer(new HtmlSanitizer($config));
}
#[Test]
public function itAllowsBoldText(): void
{
$html = '<p>Texte <strong>en gras</strong></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsItalicText(): void
{
$html = '<p>Texte <em>en italique</em></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsUnorderedLists(): void
{
$html = '<ul><li>Élément 1</li><li>Élément 2</li></ul>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsOrderedLists(): void
{
$html = '<ol><li>Premier</li><li>Deuxième</li></ol>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itAllowsLinksWithSafeAttributes(): void
{
$html = '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">Lien</a></p>';
self::assertSame($html, $this->sanitizer->sanitize($html));
}
#[Test]
public function itStripsScriptTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><script>alert("xss")</script>');
self::assertStringNotContainsString('<script>', $result);
self::assertStringNotContainsString('alert', $result);
}
#[Test]
public function itStripsEventHandlers(): void
{
$result = $this->sanitizer->sanitize('<p onclick="alert(1)">Texte</p>');
self::assertStringNotContainsString('onclick', $result);
self::assertStringContainsString('Texte', $result);
}
#[Test]
public function itStripsStyleTags(): void
{
$result = $this->sanitizer->sanitize('<style>body{display:none}</style><p>Texte</p>');
self::assertStringNotContainsString('<style>', $result);
self::assertStringContainsString('Texte', $result);
}
#[Test]
public function itStripsIframeTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><iframe src="evil.com"></iframe>');
self::assertStringNotContainsString('<iframe>', $result);
}
#[Test]
public function itStripsImgTags(): void
{
$result = $this->sanitizer->sanitize('<p>Texte</p><img src="x" onerror="alert(1)">');
self::assertStringNotContainsString('<img', $result);
self::assertStringNotContainsString('onerror', $result);
}
#[Test]
public function itHandlesEmptyString(): void
{
self::assertSame('', $this->sanitizer->sanitize(''));
}
#[Test]
public function itHandlesPlainText(): void
{
$result = $this->sanitizer->sanitize('Texte simple sans HTML');
self::assertSame('Texte simple sans HTML', $result);
}
#[Test]
public function itStripsJavascriptLinks(): void
{
$result = $this->sanitizer->sanitize('<a href="javascript:alert(1)">Clic</a>');
self::assertStringNotContainsString('javascript:', $result);
}
#[Test]
#[DataProvider('richContentProvider')]
public function itPreservesRichContent(string $input, string $expectedSubstring): void
{
$result = $this->sanitizer->sanitize($input);
self::assertStringContainsString($expectedSubstring, $result);
}
/** @return iterable<string, array{string, string}> */
public static function richContentProvider(): iterable
{
yield 'gras' => ['<strong>gras</strong>', '<strong>gras</strong>'];
yield 'italique' => ['<em>italique</em>', '<em>italique</em>'];
yield 'liste à puces' => ['<ul><li>item</li></ul>', '<li>item</li>'];
yield 'liste numérotée' => ['<ol><li>item</li></ol>', '<ol>'];
yield 'paragraphe' => ['<p>texte</p>', '<p>texte</p>'];
yield 'lien' => ['<a href="https://x.com">lien</a>', 'href="https://x.com"'];
}
}