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

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