Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ Politique unique dans `App\Security\SecurityHeaders`, appliquée par le Kernel
- **Pas de fichier orphelin** : si le quota est atteint ou si l'enregistrement échoue, le fichier copié est retiré ; à la suppression d'un document, la ligne part d'abord puis le fichier ; à la suppression d'un **groupe**, ses fichiers sont listés avant (`GroupFilesPurgerInterface::filesOf`) puis retirés après (`remove`, au mieux) — `NoGroupFilesPurger` (Null Object) pour les scripts et tests.
- **Nom d'origine borné** : sans chemin ni caractère de contrôle, 150 caractères au plus (extension conservée, jamais coupé au milieu d'un caractère), `document` s'il ne reste rien.

- **Images ré-encodées (#432)** : `Group\Service\ImageSanitizer` décode puis ré-encode chaque JPEG/PNG avec GD avant stockage : les octets ajoutés après l'image (fichier polyglotte), les segments cachés et les métadonnées (dont la position GPS d'une photo) disparaissent ; l'orientation EXIF d'une photo de téléphone est appliquée avant de la retirer. Dimensions déclarées contrôlées **avant** le décodage (12 000 px de côté, 40 millions de pixels : sinon `InvalidUploadException`). Les PDF sont stockés tels quels. Sans l'extension GD, l'image est copiée telle quelle (`composer.json` la suggère) : à vérifier sur le serveur.
- **Plafond de volume (#432)** : en plus du nombre (20), 100 Mo par groupe (`GroupDocumentService::MAX_BYTES_PER_GROUP`, `totalBytesByGroup`). Contrôlé une première fois AVANT d'écrire sur le disque, puis dans la transaction qui verrouille le groupe (c'est ce second contrôle qui fait foi). La taille enregistrée est celle du fichier réellement stocké (après ré-encodage).

## Demandes de créneau : intégrité (#221)

- **Date épinglée à la réponse** : « Accepter » ou « Refuser » envoie la date que le titulaire a sous les yeux (`occurrenceDate`, portée par `data-occurrence-date` sur les boutons) et l'`UPDATE` atomique ne s'applique que si la demande la porte encore (`respond(..., $expectedOccurrenceDate)`). Si le groupe demandeur l'a changée entre-temps : `RequestChangedException` → **409**, la demande reste en attente et le titulaire la revoit avec sa nouvelle date. `AvailabilityService::respond` et l'API **exigent** cette date (le paramètre est optionnel seulement dans le dépôt, pour les données de test).
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@
},
"config": {
"sort-packages": true
},
"suggest": {
"ext-gd": "Ré-encodage des images envoyées (#432) : sans elle, JPEG et PNG sont stockés tels quels",
"ext-exif": "Orientation des photos de téléphone appliquée avant de retirer les métadonnées (#432)"
}
}
3 changes: 3 additions & 0 deletions src/Group/Repository/GroupDocumentRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public function findByGroup(int $groupId): array;

public function countByGroup(int $groupId): int;

/** Volume total des documents d'un groupe, en octets (plafond de volume, #432). */
public function totalBytesByGroup(int $groupId): int;

/**
* Verrouille le groupe jusqu'à la fin de la transaction en cours (#222) : les envois d'un MÊME groupe passent l'un après
* l'autre, ce qui rend atomique « compter puis ajouter » (le quota ne peut plus être dépassé par des envois simultanés).
Expand Down
8 changes: 8 additions & 0 deletions src/Group/Repository/MysqlGroupDocumentRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public function countByGroup(int $groupId): int
return (int) $statement->fetchColumn();
}

public function totalBytesByGroup(int $groupId): int
{
$statement = $this->pdo->prepare('SELECT COALESCE(SUM(size_bytes), 0) FROM group_documents WHERE group_id = :group_id');
$statement->execute(['group_id' => $groupId]);

return (int) $statement->fetchColumn();
}

public function save(GroupDocument $document): GroupDocument
{
$statement = $this->pdo->prepare(
Expand Down
41 changes: 36 additions & 5 deletions src/Group/Service/GroupDocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
final class GroupDocumentService implements GroupFilesPurgerInterface
{
private const MAX_SIZE_BYTES = 10 * 1024 * 1024;
/** Volume total par groupe (#432) : le nombre de fichiers seul laisserait 20 × 10 Mo sur un hébergement au disque compté. */
public const MAX_BYTES_PER_GROUP = 100 * 1024 * 1024;
/** Longueur maximale du nom d'origine conservé (la colonne en accepte 255) : borné avant la base, jamais une erreur SQL. */
private const MAX_NAME_LENGTH = 150;

Expand All @@ -33,6 +35,8 @@ public function __construct(
private readonly TransactionRunner $transactions,
private readonly string $storagePath,
private readonly int $maxDocumentsPerGroup,
private readonly int $maxBytesPerGroup = self::MAX_BYTES_PER_GROUP,
private readonly ImageSanitizer $images = new ImageSanitizer(),
) {
}

Expand All @@ -51,25 +55,36 @@ public function upload(int $groupId, int $actorUserId, string $tmpFilePath, stri
throw new InvalidUploadException('Type de fichier non autorisé (PDF, JPEG, PNG uniquement).');
}

// Avant toute écriture sur le disque : un groupe déjà plein ne fait pas copier un fichier pour rien (verdict définitif plus bas).
$this->assertRoomFor($groupId, $declaredSize);

$storedName = bin2hex(random_bytes(16)) . '.' . self::ALLOWED_MIME_TYPES[$realMimeType];
$destinationPath = $this->storagePath . '/' . $storedName;

if (!is_dir($this->storagePath) && !mkdir($this->storagePath, 0700, true) && !is_dir($this->storagePath)) {
throw new InvalidUploadException('Impossible de préparer le stockage des documents.');
}

if (!copy($tmpFilePath, $destinationPath)) {
throw new InvalidUploadException("Échec de l'enregistrement du fichier.");
try {
if ($realMimeType === 'application/pdf') {
if (!copy($tmpFilePath, $destinationPath)) {
throw new InvalidUploadException("Échec de l'enregistrement du fichier.");
}
} else {
$this->images->reencode($tmpFilePath, $realMimeType, $destinationPath);
}
} catch (\Throwable $e) {
@unlink($destinationPath);

throw $e;
}

try {
// « Compter puis ajouter » est ATOMIQUE : le groupe est verrouillé le temps de la transaction, donc des envois
// simultanés ne peuvent pas dépasser le quota.
return $this->transactions->run(function () use ($groupId, $actorUserId, $originalName, $storedName, $realMimeType, $destinationPath): GroupDocument {
$this->documentRepository->lockGroupQuota($groupId);
if ($this->documentRepository->countByGroup($groupId) >= $this->maxDocumentsPerGroup) {
throw new StorageQuotaExceededException('Nombre maximum de documents atteint pour ce groupe.');
}
$this->assertRoomFor($groupId, filesize($destinationPath));

return $this->documentRepository->save(new GroupDocument(
0,
Expand All @@ -89,6 +104,22 @@ public function upload(int $groupId, int $actorUserId, string $tmpFilePath, stri
}
}

/**
* Place pour un document de $bytes octets : nombre ET volume du groupe. Appelé une première fois avant d'écrire, puis dans la
* transaction qui verrouille le groupe (c'est ce second contrôle qui fait foi face à des envois simultanés).
*
* @throws StorageQuotaExceededException
*/
private function assertRoomFor(int $groupId, int $bytes): void
{
if ($this->documentRepository->countByGroup($groupId) >= $this->maxDocumentsPerGroup) {
throw new StorageQuotaExceededException('Nombre maximum de documents atteint pour ce groupe.');
}
if ($this->documentRepository->totalBytesByGroup($groupId) + $bytes > $this->maxBytesPerGroup) {
throw new StorageQuotaExceededException('Le volume maximal de documents est atteint pour ce groupe.');
}
}

/** @return list<GroupDocument> */
public function listForGroup(int $groupId, int $actorUserId): array
{
Expand Down
85 changes: 85 additions & 0 deletions src/Group/Service/ImageSanitizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace App\Group\Service;

use App\Group\Exception\InvalidUploadException;

/**
* Image envoyée par un utilisateur (#432) : décodée puis ré-encodée avec GD avant d'être stockée. Ce qui n'est pas de l'image
* disparaît : données ajoutées après l'image (fichier « polyglotte »), segments cachés, métadonnées dont la POSITION GPS d'une photo.
* L'orientation d'une photo de téléphone (EXIF) est appliquée avant de la retirer. Taille déclarée contrôlée AVANT le décodage
* (une « bombe » de quelques octets déclarant des centaines de millions de pixels épuiserait la mémoire). Sans l'extension GD, le
* fichier est stocké tel quel : le type réel (finfo) reste contrôlé en amont.
*/
final class ImageSanitizer
{
public const MAX_PIXELS = 40_000_000;
public const MAX_SIDE = 12000;
private const JPEG_QUALITY = 90;

/** @throws InvalidUploadException image illisible ou trop grande */
public function reencode(string $source, string $mimeType, string $destination): void
{
if (!extension_loaded('gd')) {
if (!copy($source, $destination)) {
throw new InvalidUploadException("Échec de l'enregistrement du fichier.");
}

return;
}

$size = @getimagesize($source);
if ($size === false) {
throw new InvalidUploadException('Image illisible.');
}
if ($size[0] < 1 || $size[1] < 1 || $size[0] > self::MAX_SIDE || $size[1] > self::MAX_SIDE || $size[0] * $size[1] > self::MAX_PIXELS) {
throw new InvalidUploadException('Image trop grande (au plus ' . self::MAX_SIDE . ' pixels de côté).');
}

$image = $mimeType === 'image/png' ? @imagecreatefrompng($source) : @imagecreatefromjpeg($source);
if ($image === false) {
throw new InvalidUploadException('Image illisible.');
}

if ($mimeType === 'image/jpeg') {
$image = $this->upright($image, $source);
}

$saved = $mimeType === 'image/png' ? $this->savePng($image, $destination) : @imagejpeg($image, $destination, self::JPEG_QUALITY);
imagedestroy($image);
if (!$saved) {
throw new InvalidUploadException("Échec de l'enregistrement du fichier.");
}
}

private function savePng(\GdImage $image, string $destination): bool
{
imagealphablending($image, false);
imagesavealpha($image, true);

return @imagepng($image, $destination, 6);
}

/** Applique l'orientation EXIF d'une photo (rotations 90/180/270 et miroirs simples) ; sans EXIF lisible, l'image est inchangée. */
private function upright(\GdImage $image, string $source): \GdImage
{
if (!function_exists('exif_read_data')) {
return $image;
}
$exif = @exif_read_data($source);
$orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1;

$result = match ($orientation) {
3 => imagerotate($image, 180, 0),
6 => imagerotate($image, -90, 0),
8 => imagerotate($image, 90, 0),
2 => imageflip($image, IMG_FLIP_HORIZONTAL) ? $image : false,
4 => imageflip($image, IMG_FLIP_VERTICAL) ? $image : false,
default => $image,
};

return $result === false ? $image : $result;
}
}
132 changes: 130 additions & 2 deletions tests/Group/Service/GroupDocumentServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ protected function tearDown(): void
parent::tearDown();
}

private function makeService(int $maxDocuments = 20): array
private function makeService(int $maxDocuments = 20, int $maxBytes = 100 * 1024 * 1024): array
{
$groupRepository = new MysqlGroupRepository($this->pdo);
$userRepository = new MysqlUserRepository($this->pdo);
$documentRepository = new MysqlGroupDocumentRepository($this->pdo);
$service = new GroupDocumentService($documentRepository, $groupRepository, new TransactionRunner($this->pdo), $this->storagePath, $maxDocuments);
$service = new GroupDocumentService($documentRepository, $groupRepository, new TransactionRunner($this->pdo), $this->storagePath, $maxDocuments, $maxBytes);

return [$service, $groupRepository, $userRepository, $documentRepository];
}
Expand Down Expand Up @@ -325,6 +325,11 @@ public function lockGroupQuota(int $groupId): void
$this->inner->lockGroupQuota($groupId);
}

public function totalBytesByGroup(int $groupId): int
{
return $this->inner->totalBytesByGroup($groupId);
}

public function save(GroupDocument $document): GroupDocument
{
throw new \PDOException('SQLSTATE : base indisponible');
Expand Down Expand Up @@ -406,4 +411,127 @@ public function testTheFilesOfAGroupCanBeListedThenRemovedWhenTheGroupIsDeleted(
$service->remove(['/inexistant/fichier.pdf']); // best effort : un fichier déjà absent n'est jamais une erreur
self::addToAssertionCount(1);
}

/** Un PNG de $width × $height fabriqué avec GD, plus d'éventuels octets parasites à la suite. */
private function pngBytes(int $width = 4, int $height = 3, string $trailing = ''): string
{
$image = imagecreatetruecolor($width, $height);
ob_start();
imagepng($image);

return (string) ob_get_clean() . $trailing;
}

/** Un JPEG de $width × $height fabriqué avec GD ; `$segments` : segments (marqueur + contenu) insérés juste après l'en-tête SOI. */
private function jpegBytes(int $width = 4, int $height = 3, string $segments = ''): string
{
$image = imagecreatetruecolor($width, $height);
ob_start();
imagejpeg($image);
$jpeg = (string) ob_get_clean();

return substr($jpeg, 0, 2) . $segments . substr($jpeg, 2);
}

/** Segment de commentaire JPEG (FFFE) : un endroit où des données parasites ou personnelles se cachent. */
private function jpegComment(string $text): string
{
return "\xFF\xFE" . pack('n', strlen($text) + 2) . $text;
}

/** Segment EXIF (APP1) portant l'orientation $orientation (6 = à tourner de 90° dans le sens horaire). */
private function exifOrientation(int $orientation): string
{
$tiff = "MM\x00\x2A\x00\x00\x00\x08" . pack('n', 1) . pack('nnN', 0x0112, 3, 1) . pack('n', $orientation) . "\x00\x00" . "\x00\x00\x00\x00";
$body = "Exif\x00\x00" . $tiff;

return "\xFF\xE1" . pack('n', strlen($body) + 2) . $body;
}

private function uploadAsManager(GroupDocumentService $service, string $content, string $name): GroupDocument
{
[, $groupRepository, $userRepository] = $this->makeService();
$group = $groupRepository->save(new Group(0, 'Groupe ' . uniqid(), null, null, 'contact@example.test'));
$manager = $this->createUser($userRepository, uniqid('m') . '@rehearsalbox.test');
$groupRepository->addMember($group->id(), $manager->id(), GroupUserRole::Gestionnaire);

return $service->upload($group->id(), $manager->id(), $this->fakeUploadedFile($content, $name), $name, strlen($content));
}

#[Test]
public function testAPictureIsReEncodedSoTrailingBytesAndHiddenSegmentsAreDropped(): void
{
[$service] = $this->makeService();

$png = $this->uploadAsManager($service, $this->pngBytes(4, 3, '<script>alert(1)</script>'), 'p.png');
$jpeg = $this->uploadAsManager($service, $this->jpegBytes(4, 3, $this->jpegComment('POSITION-GPS-SECRETE')), 'p.jpg');

foreach ([$png, $jpeg] as $document) {
$stored = (string) file_get_contents($service->pathOf($document));
self::assertStringNotContainsString('<script>', $stored);
self::assertStringNotContainsString('POSITION-GPS-SECRETE', $stored);
self::assertSame(['image/' . ($document === $png ? 'png' : 'jpeg'), 4, 3], [getimagesizefromstring($stored)['mime'], getimagesizefromstring($stored)[0], getimagesizefromstring($stored)[1]]);
self::assertSame(strlen($stored), $document->sizeBytes(), 'la taille enregistrée est celle du fichier réellement stocké');
}
}

#[Test]
public function testThePhoneOrientationIsAppliedBeforeTheMetadataIsDropped(): void
{
[$service] = $this->makeService();

$document = $this->uploadAsManager($service, $this->jpegBytes(4, 2, $this->exifOrientation(6)), 'portrait.jpg');

$size = getimagesizefromstring((string) file_get_contents($service->pathOf($document)));
self::assertSame([2, 4], [$size[0], $size[1]], 'une photo prise « debout » reste debout une fois l\'EXIF retiré');
}

#[Test]
public function testAnImageWithAHugeDeclaredSizeIsRefusedWithoutBeingDecoded(): void
{
[$service] = $this->makeService();
$header = "\x89PNG\r\n\x1a\n" . pack('N', 13) . 'IHDR' . pack('NNCCCCC', 30000, 30000, 8, 2, 0, 0, 0) . pack('N', 0);

$this->expectException(InvalidUploadException::class);
$this->uploadAsManager($service, $header, 'bombe.png');
}

#[Test]
public function testAnImageThatCannotBeDecodedIsRefused(): void
{
[$service] = $this->makeService();

$this->expectException(InvalidUploadException::class);
$this->uploadAsManager($service, substr($this->pngBytes(), 0, 40), 'coupe.png');
}

#[Test]
public function testAPdfIsStoredUntouched(): void
{
[$service] = $this->makeService();

$document = $this->uploadAsManager($service, "%PDF-1.4\nbonjour", 'f.pdf');

self::assertSame("%PDF-1.4\nbonjour", file_get_contents($service->pathOf($document)));
}

#[Test]
public function testTheTotalVolumeOfAGroupIsCappedAndARefusedFileLeavesNothingOnDisk(): void
{
[$service, $groupRepository, $userRepository] = $this->makeService(20, 1500);
$group = $groupRepository->save(new Group(0, 'Groupe Volume', null, null, 'contact@example.test'));
$manager = $this->createUser($userRepository, 'vol@rehearsalbox.test');
$groupRepository->addMember($group->id(), $manager->id(), GroupUserRole::Gestionnaire);
$pdf = '%PDF-1.4 ' . str_repeat('x', 800);

$service->upload($group->id(), $manager->id(), $this->fakeUploadedFile($pdf, 'a.pdf'), 'a.pdf', strlen($pdf));
try {
$service->upload($group->id(), $manager->id(), $this->fakeUploadedFile($pdf, 'b.pdf'), 'b.pdf', strlen($pdf));
self::fail('le deuxième fichier dépasse le volume du groupe');
} catch (StorageQuotaExceededException $e) {
self::assertStringContainsString('volume', $e->getMessage());
}

self::assertCount(1, $this->storedFiles());
}
}
Loading