From 031ddd3b32e9163f2ea1c0edd28a1d99d2e2202f Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 13:55:43 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(StorageShard):=20calcul=20?= =?UTF-8?q?du=20sous-dossier=20de=20r=C3=A9partition=20du=20stockage=20(#6?= =?UTF-8?q?09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/File/StorageShard.php | 71 +++++++++++++++++++++ tests/Unit/Service/StorageShardTest.php | 84 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/Service/File/StorageShard.php create mode 100644 tests/Unit/Service/StorageShardTest.php diff --git a/src/Service/File/StorageShard.php b/src/Service/File/StorageShard.php new file mode 100644 index 00000000..e5835871 --- /dev/null +++ b/src/Service/File/StorageShard.php @@ -0,0 +1,71 @@ +/`. */ + public static function originalPath(string $filename): string + { + return self::forFilename($filename).'/'.$filename; + } + + /** Chemin relatif d'une vignette : `thumbs//`. */ + public static function thumbnailPath(string $filename): string + { + return 'thumbs/'.self::forFilename($filename).'/'.$filename; + } + + /** + * Équivalent au nouveau format d'un chemin à l'ancien format + * (`AAAA/MM/` ou `thumbs/`). Tout autre chemin, y compris un + * chemin déjà réparti, est renvoyé tel quel. + */ + public static function shardedPathOf(string $relativePath): string + { + if (preg_match(self::LEGACY_ORIGINAL_PATTERN, $relativePath) === 1) { + return self::originalPath(basename($relativePath)); + } + + if (preg_match(self::LEGACY_THUMBNAIL_PATTERN, $relativePath) === 1) { + return self::thumbnailPath(basename($relativePath)); + } + + return $relativePath; + } +} diff --git a/tests/Unit/Service/StorageShardTest.php b/tests/Unit/Service/StorageShardTest.php new file mode 100644 index 00000000..5a5878ed --- /dev/null +++ b/tests/Unit/Service/StorageShardTest.php @@ -0,0 +1,84 @@ +assertSame('ef', StorageShard::forFilename('0192f3a0-7c1b-7d2e-8a4f-1234567890ef.jpg')); + $this->assertSame('ef', StorageShard::forFilename('0192f3a0-7c1b-7d2e-8a4f-1234567890EF.nef')); + } + + public function testShardIgnoresExtensionAndDirectory(): void + { + $this->assertSame('ab', StorageShard::forFilename('2026/10/0192f3a0-7c1b-7d2e-8a4f-1234567890ab.pdf')); + $this->assertSame('ab', StorageShard::forFilename('thumbs/0192f3a0-7c1b-7d2e-8a4f-1234567890ab.jpg')); + } + + public function testNonHexStemFallsBackToStableHash(): void + { + $shard = StorageShard::forFilename('photo.nef'); + + $this->assertMatchesRegularExpression('/^[0-9a-f]{2}$/', $shard); + $this->assertSame($shard, StorageShard::forFilename('autre/dossier/photo.nef')); + } + + public function testConsecutiveUuidV7SpreadOverManyShards(): void + { + $shards = []; + for ($i = 0; $i < 5000; ++$i) { + $shards[StorageShard::forFilename(Uuid::v7()->toRfc4122().'.jpg')] = true; + } + + $this->assertGreaterThan(200, count($shards), 'Un import en rafale doit se répartir sur ~256 sous-dossiers'); + } + + public function testOriginalPathHasNoDateAndOneShardLevel(): void + { + $this->assertSame( + 'ef/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.jpg', + StorageShard::originalPath('0192f3a0-7c1b-7d2e-8a4f-1234567890ef.jpg'), + ); + } + + public function testThumbnailPathIsShardedUnderThumbs(): void + { + $this->assertSame( + 'thumbs/ef/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.jpg', + StorageShard::thumbnailPath('0192f3a0-7c1b-7d2e-8a4f-1234567890ef.jpg'), + ); + } + + public function testShardedPathOfLegacyPath(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + + $this->assertSame("ef/{$uuid}.jpg", StorageShard::shardedPathOf("2026/10/{$uuid}.jpg")); + $this->assertSame("thumbs/ef/{$uuid}.jpg", StorageShard::shardedPathOf("thumbs/{$uuid}.jpg")); + } + + public function testShardedPathOfAlreadyShardedPathIsUnchanged(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + + $this->assertSame("ef/{$uuid}.jpg", StorageShard::shardedPathOf("ef/{$uuid}.jpg")); + $this->assertSame("thumbs/ef/{$uuid}.jpg", StorageShard::shardedPathOf("thumbs/ef/{$uuid}.jpg")); + } + + public function testShardedPathOfUnknownLayoutIsUnchanged(): void + { + $this->assertSame('demo/photo.jpg', StorageShard::shardedPathOf('demo/photo.jpg')); + } +} From fadfe3c456074bbd3ebed9410e035479fb574ddb Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 13:56:17 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat(StorageService):=20=C3=A9c?= =?UTF-8?q?rit=20les=20originaux=20dans=20un=20sous-dossier=20r=C3=A9parti?= =?UTF-8?q?,=20repli=20de=20lecture=20et=20de=20suppression=20(#609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/File/StorageService.php | 24 ++- .../Service/StorageServiceShardingTest.php | 155 ++++++++++++++++++ 2 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Service/StorageServiceShardingTest.php diff --git a/src/Service/File/StorageService.php b/src/Service/File/StorageService.php index e3fe1cff..480adff1 100644 --- a/src/Service/File/StorageService.php +++ b/src/Service/File/StorageService.php @@ -14,7 +14,11 @@ * création des répertoires, déplacement du fichier temporaire. * * Choix : - * - Stockage dans `var/storage/{year}/{month}/{uuid}.{ext}`. + * - Stockage dans `var/storage/{shard}/{uuid}.{ext}` (#609) : `shard` = 2 derniers + * caractères hexadécimaux de l'UUID, pour qu'aucun répertoire n'approche la limite + * de 20 000 fichiers de l'hébergeur (cf. StorageShard). Les anciens fichiers + * `{year}/{month}/{uuid}.{ext}` restent lisibles tant que `app:storage:shard` + * ne les a pas déplacés. * - Retourne un chemin relatif à `var/storage/` pour que l'entité soit * indépendante de l'emplacement absolu du projet. * - Pas de lib externe (VichUploader, Flysystem) — contrôle total, dépendances minimales. @@ -80,8 +84,6 @@ public function __construct( */ public function store(UploadedFile $file): array { - $year = date('Y'); - $month = date('m'); $uuid = \Symfony\Component\Uid\Uuid::v7()->toRfc4122(); $clientExt = strtolower($file->getClientOriginalExtension()); // Utilise l'extension client si disponible, sinon détecte depuis le contenu (finfo) @@ -105,13 +107,13 @@ public function store(UploadedFile $file): array $ext = $neutralized ? 'bin' : $originalExt; - $subDir = sprintf('%s/%s', $year, $month); $filename = sprintf('%s.%s', $uuid, $ext); + $relativePath = StorageShard::originalPath($filename); - $file->move($this->storageDir.'/'.$subDir, $filename); + $file->move($this->storageDir.'/'.dirname($relativePath), $filename); return [ - 'path' => sprintf('%s/%s', $subDir, $filename), + 'path' => $relativePath, 'neutralized' => $neutralized, ]; } @@ -150,6 +152,10 @@ public function delete(string $relativePath): void { $fullPath = $this->storageDir.'/'.$relativePath; + if (!file_exists($fullPath)) { + $fullPath = $this->storageDir.'/'.StorageShard::shardedPathOf($relativePath); + } + if (file_exists($fullPath)) { unlink($fullPath); } @@ -168,6 +174,12 @@ public function getAbsolutePath(string $relativePath): string $candidate = $this->storageDir.'/'.$relativePath; $resolved = realpath($candidate); + // Repli (#609) : un fichier déplacé par app:storage:shard alors que + // l'entité en mémoire porte encore l'ancien chemin. + if ($resolved === false) { + $resolved = realpath($this->storageDir.'/'.StorageShard::shardedPathOf($relativePath)); + } + if ($resolved === false) { return $candidate; } diff --git a/tests/Unit/Service/StorageServiceShardingTest.php b/tests/Unit/Service/StorageServiceShardingTest.php new file mode 100644 index 00000000..5a6be037 --- /dev/null +++ b/tests/Unit/Service/StorageServiceShardingTest.php @@ -0,0 +1,155 @@ +/.`, + * les anciens (`AAAA/MM/.`) restent lisibles tant qu'ils ne sont pas migrés. + */ +final class StorageServiceShardingTest extends TestCase +{ + private string $storageDir; + private string $tmpDir; + + protected function setUp(): void + { + $base = sys_get_temp_dir().'/hc-shard-'.uniqid(); + $this->storageDir = $base.'/storage'; + $this->tmpDir = $base.'/tmp'; + mkdir($this->storageDir, 0755, true); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + $this->removeTree(dirname($this->storageDir)); + } + + public function testStoreWritesToShardedPathWithoutDate(): void + { + $service = new StorageService($this->storageDir); + + $result = $service->store($this->upload('doc.txt', 'bonjour')); + + $this->assertMatchesRegularExpression('#^[0-9a-f]{2}/[0-9a-f-]{36}\.txt$#', $result['path']); + $this->assertSame('bonjour', file_get_contents($this->storageDir.'/'.$result['path'])); + } + + public function testNeutralizedFileIsAlsoSharded(): void + { + $service = new StorageService($this->storageDir); + + $result = $service->store($this->upload('page.html', 'x')); + + $this->assertTrue($result['neutralized']); + $this->assertMatchesRegularExpression('#^[0-9a-f]{2}/[0-9a-f-]{36}\.bin$#', $result['path']); + } + + public function testShardMatchesUuidEndInPath(): void + { + $service = new StorageService($this->storageDir); + + $path = $service->store($this->upload('a.txt', 'a'))['path']; + + $this->assertSame(substr($path, 0, 2), substr($path, -6, 2), 'Le shard est la fin du nom (avant ".txt")'); + } + + public function testGetAbsolutePathResolvesLegacyPathWhenFileStillThere(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + mkdir($this->storageDir.'/2026/10', 0755, true); + file_put_contents($this->storageDir."/2026/10/{$uuid}.jpg", 'old'); + + $absolute = (new StorageService($this->storageDir))->getAbsolutePath("2026/10/{$uuid}.jpg"); + + $this->assertSame(realpath($this->storageDir)."/2026/10/{$uuid}.jpg", $absolute); + } + + public function testGetAbsolutePathFallsBackToShardedPathWhenLegacyFileWasMoved(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + mkdir($this->storageDir.'/ef', 0755, true); + file_put_contents($this->storageDir."/ef/{$uuid}.jpg", 'moved'); + + $absolute = (new StorageService($this->storageDir))->getAbsolutePath("2026/10/{$uuid}.jpg"); + + $this->assertSame(realpath($this->storageDir)."/ef/{$uuid}.jpg", $absolute); + } + + public function testGetAbsolutePathFallsBackForLegacyThumbnail(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + mkdir($this->storageDir.'/thumbs/ef', 0755, true); + file_put_contents($this->storageDir."/thumbs/ef/{$uuid}.jpg", 'thumb'); + + $absolute = (new StorageService($this->storageDir))->getAbsolutePath("thumbs/{$uuid}.jpg"); + + $this->assertSame(realpath($this->storageDir)."/thumbs/ef/{$uuid}.jpg", $absolute); + } + + public function testGetAbsolutePathStillReturnsCandidateWhenNothingExists(): void + { + $absolute = (new StorageService($this->storageDir))->getAbsolutePath('2026/10/absent.jpg'); + + $this->assertSame($this->storageDir.'/2026/10/absent.jpg', $absolute); + } + + public function testPathTraversalIsStillRejected(): void + { + file_put_contents(dirname($this->storageDir).'/secret.txt', 'x'); + + $this->expectException(\RuntimeException::class); + + (new StorageService($this->storageDir))->getAbsolutePath('../secret.txt'); + } + + public function testDeleteRemovesFileMovedToShardedPath(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + mkdir($this->storageDir.'/ef', 0755, true); + file_put_contents($this->storageDir."/ef/{$uuid}.jpg", 'moved'); + + (new StorageService($this->storageDir))->delete("2026/10/{$uuid}.jpg"); + + $this->assertFileDoesNotExist($this->storageDir."/ef/{$uuid}.jpg"); + } + + public function testDeleteRemovesLegacyFile(): void + { + $uuid = '0192f3a0-7c1b-7d2e-8a4f-1234567890ef'; + mkdir($this->storageDir.'/2026/10', 0755, true); + file_put_contents($this->storageDir."/2026/10/{$uuid}.jpg", 'old'); + + (new StorageService($this->storageDir))->delete("2026/10/{$uuid}.jpg"); + + $this->assertFileDoesNotExist($this->storageDir."/2026/10/{$uuid}.jpg"); + } + + private function upload(string $name, string $content): UploadedFile + { + $path = $this->tmpDir.'/'.uniqid('up-').'-'.$name; + file_put_contents($path, $content); + + return new UploadedFile($path, $name, null, null, true); + } + + private function removeTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $f) { + $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname()); + } + rmdir($dir); + } +} From e24d3be92199376f1b29266e7d00125c0e52611d Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 13:56:48 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20feat(RawPreviewCache):=20r?= =?UTF-8?q?=C3=A9partit=20le=20cache=20en=20sous-dossiers,=20cl=C3=A9=20st?= =?UTF-8?q?able=20=C3=A0=20travers=20un=20d=C3=A9placement=20(#609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Media/RawPreviewCacheInterface.php | 8 ++ src/Service/Media/RawPreviewCache.php | 38 +++++++-- tests/Service/RawPreviewCacheTest.php | 78 ++++++++++++++++--- 3 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/Interface/Media/RawPreviewCacheInterface.php b/src/Interface/Media/RawPreviewCacheInterface.php index accdea1f..e4af207d 100644 --- a/src/Interface/Media/RawPreviewCacheInterface.php +++ b/src/Interface/Media/RawPreviewCacheInterface.php @@ -26,4 +26,12 @@ public function put(string $sourceRelativePath, string $jpegData): void; * Retire la preview du cache. Sans effet si rien n'est caché. */ public function evict(string $sourceRelativePath): void; + + /** + * Supprime les previews de l'ancien format à plat (avant #609), devenues + * inatteignables. Le cache se régénère à la demande. + * + * @return int Nombre de fichiers supprimés + */ + public function purgeLegacyFlatEntries(): int; } diff --git a/src/Service/Media/RawPreviewCache.php b/src/Service/Media/RawPreviewCache.php index 3d222c78..5205d334 100644 --- a/src/Service/Media/RawPreviewCache.php +++ b/src/Service/Media/RawPreviewCache.php @@ -17,8 +17,13 @@ * - Le nom du fichier de cache est dérivé du chemin source, pas stocké en base : * pas de migration ni de champ à maintenir, et le cache reste un détail * d'implémentation qu'on peut vider entièrement sans rien casser. - * - Le hash aplatit le chemin (qui contient des slashes) en un nom de fichier - * unique, ce qui évite au passage toute traversée de répertoire. + * - Le hash aplatit le nom en un nom de fichier unique, ce qui évite au passage + * toute traversée de répertoire. + * - #609 : le hash porte sur le NOM du fichier source (`.`, stable), + * pas sur son chemin complet, sinon déplacer l'original (app:storage:shard) + * orphelinerait sa preview. Les entrées sont réparties en `previews//` + * (xx = 2 premiers caractères du hash) pour rester sous les 20 000 fichiers + * par répertoire de l'hébergeur. * - Dégradation gracieuse : un cache illisible ou non inscriptible (disque * plein, droits) fait retomber sur une génération à la volée, jamais sur une * erreur. @@ -51,7 +56,7 @@ public function get(string $sourceRelativePath): ?string public function put(string $sourceRelativePath, string $jpegData): void { - $dir = $this->cacheDir(); + $dir = dirname($this->pathFor($sourceRelativePath)); if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { return; @@ -77,14 +82,35 @@ public function put(string $sourceRelativePath, string $jpegData): void */ public function evict(string $sourceRelativePath): void { - $path = $this->pathFor($sourceRelativePath); + foreach ([$this->pathFor($sourceRelativePath), $this->legacyPathFor($sourceRelativePath)] as $path) { + if (is_file($path)) { + @unlink($path); + } + } + } + + public function purgeLegacyFlatEntries(): int + { + $purged = 0; - if (is_file($path)) { - @unlink($path); + foreach (glob($this->cacheDir() . '/*.jpg') ?: [] as $file) { + if (@unlink($file)) { + ++$purged; + } } + + return $purged; } private function pathFor(string $sourceRelativePath): string + { + $hash = hash('xxh128', basename($sourceRelativePath)); + + return $this->cacheDir() . '/' . substr($hash, 0, 2) . '/' . $hash . '.jpg'; + } + + /** Format d'avant #609 : à plat, haché sur le chemin complet. */ + private function legacyPathFor(string $sourceRelativePath): string { return $this->cacheDir() . '/' . hash('xxh128', $sourceRelativePath) . '.jpg'; } diff --git a/tests/Service/RawPreviewCacheTest.php b/tests/Service/RawPreviewCacheTest.php index 2d0fe254..5a317451 100644 --- a/tests/Service/RawPreviewCacheTest.php +++ b/tests/Service/RawPreviewCacheTest.php @@ -27,16 +27,16 @@ protected function setUp(): void protected function tearDown(): void { - $dir = $this->storageDir . '/previews'; - if (is_dir($dir)) { - foreach (glob($dir . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($dir); + if (!is_dir($this->storageDir)) { + return; } - if (is_dir($this->storageDir)) { - rmdir($this->storageDir); + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->storageDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $f) { + $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname()); } + rmdir($this->storageDir); } public function testStoresAndReturnsPreview(): void @@ -87,7 +87,7 @@ public function testCacheKeyDoesNotLeakSourcePathStructure(): void $cache = new RawPreviewCache($this->storageDir); $cache->put('2026/07/photo.nef', 'jpeg-bytes'); - $files = glob($this->storageDir . '/previews/*') ?: []; + $files = glob($this->storageDir . '/previews/*/*.jpg') ?: []; $this->assertCount(1, $files); // Un chemin source contient des slashes : le nom de cache doit être plat, @@ -96,4 +96,64 @@ public function testCacheKeyDoesNotLeakSourcePathStructure(): void $this->assertStringNotContainsString('/', $name); $this->assertStringNotContainsString('..', $name); } + + // ── #609 : répartition en sous-dossiers, clé stable à travers un déplacement ── + + public function testPreviewIsStoredInShardSubdirectory(): void + { + $cache = new RawPreviewCache($this->storageDir); + $cache->put('2026/07/photo.nef', 'jpeg-bytes'); + + $this->assertCount(0, glob($this->storageDir . '/previews/*.jpg') ?: [], 'Plus de fichier à plat'); + $this->assertCount(1, glob($this->storageDir . '/previews/*/*.jpg') ?: []); + $this->assertMatchesRegularExpression('#/previews/[0-9a-f]{2}/#', (glob($this->storageDir . '/previews/*/*.jpg') ?: [''])[0]); + } + + public function testKeyIsStableWhenSourceIsMovedToShardedPath(): void + { + $cache = new RawPreviewCache($this->storageDir); + $cache->put('2026/07/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.nef', 'jpeg-bytes'); + + // app:storage:shard déplace l'original : la preview déjà calculée reste valable. + $this->assertSame('jpeg-bytes', $cache->get('ef/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.nef')); + } + + public function testEvictFromMovedPathRemovesPreviewCachedUnderLegacyPath(): void + { + $cache = new RawPreviewCache($this->storageDir); + $cache->put('2026/07/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.nef', 'jpeg-bytes'); + + $cache->evict('ef/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.nef'); + + $this->assertNull($cache->get('2026/07/0192f3a0-7c1b-7d2e-8a4f-1234567890ef.nef')); + } + + public function testEvictAlsoRemovesLegacyFlatEntry(): void + { + $legacyDir = $this->storageDir . '/previews'; + mkdir($legacyDir, 0755, true); + $legacy = $legacyDir . '/' . hash('xxh128', '2026/07/old.nef') . '.jpg'; + file_put_contents($legacy, 'old-cache'); + + (new RawPreviewCache($this->storageDir))->evict('2026/07/old.nef'); + + $this->assertFileDoesNotExist($legacy); + } + + public function testPurgeLegacyRemovesOnlyFlatEntries(): void + { + $cache = new RawPreviewCache($this->storageDir); + $cache->put('2026/07/new.nef', 'new-cache'); + file_put_contents($this->storageDir . '/previews/' . hash('xxh128', '2026/07/old.nef') . '.jpg', 'old-cache'); + + $this->assertSame(1, $cache->purgeLegacyFlatEntries()); + + $this->assertSame('new-cache', $cache->get('2026/07/new.nef')); + $this->assertCount(0, glob($this->storageDir . '/previews/*.jpg') ?: []); + } + + public function testPurgeLegacyIsSilentWithoutCacheDirectory(): void + { + $this->assertSame(0, (new RawPreviewCache($this->storageDir))->purgeLegacyFlatEntries()); + } } From d1524b9645a98d6ea46624412fbbabb68ef1f0e6 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 13:57:23 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20feat(ThumbnailService):=20range?= =?UTF-8?q?=20les=20vignettes=20dans=20thumbs//=20(#609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/Media/ThumbnailService.php | 18 +++++++++------- tests/Service/ThumbnailServiceRawTest.php | 21 ++++++++----------- tests/Service/ThumbnailServiceRealRawTest.php | 16 +++++++------- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/Service/Media/ThumbnailService.php b/src/Service/Media/ThumbnailService.php index c9c06fd0..1b2fd412 100644 --- a/src/Service/Media/ThumbnailService.php +++ b/src/Service/Media/ThumbnailService.php @@ -7,6 +7,7 @@ use App\Exception\Video\VideoThumbnailExtractionException; use App\Interface\Media\ExifThumbnailExtractorInterface; use App\Interface\VideoThumbnailExtractorInterface; +use App\Service\File\StorageShard; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use RonanLenouvel\RawPreviewExtractor\Exception\RawPreviewExtractorException; @@ -23,7 +24,7 @@ * - Utilise l'extension PHP native GD (disponible sur o2switch et la plupart des hébergements). * - Retourne null si GD est absent ou si la génération échoue — le Media est quand même * créé, juste sans thumbnail. - * - Thumbnail stocké dans var/storage/thumbs/{uuid}.jpg (JPEG q=80 pour équilibre taille/qualité). + * - Thumbnail stocké dans var/storage/thumbs/{shard}/{uuid}.jpg (répartition #609, JPEG q=80 pour équilibre taille/qualité). * - Taille max : 320px de large, hauteur proportionnelle. * - Les fichiers source sont stockés en clair — lecture directe sans déchiffrement. * - Fichiers RAW (CR2/CR3/NEF/ARW/DNG) : GD ne sait pas les décoder. On extrait @@ -47,7 +48,7 @@ public function __construct( * Génère un thumbnail et retourne son chemin relatif, ou null si impossible. * * @param string $absolutePath Chemin absolu de l'image source (stockée en clair sur disque) - * @return string|null Chemin relatif du thumbnail (ex: "thumbs/uuid.jpg") ou null + * @return string|null Chemin relatif du thumbnail (ex: "thumbs/ef/uuid.jpg") ou null */ public function generate(string $absolutePath): ?string { @@ -216,15 +217,16 @@ private function resizeAndSave(\GdImage $source, Orientation $orientation): ?str imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbW, $thumbH, $srcW, $srcH); imagedestroy($source); - $thumbDir = $this->storageDir.'/thumbs'; + $uuid = \Symfony\Component\Uid\Uuid::v7()->toRfc4122(); + $filename = $uuid.'.jpg'; + $relativePath = StorageShard::thumbnailPath($filename); + $fullPath = $this->storageDir.'/'.$relativePath; + + $thumbDir = dirname($fullPath); if (!is_dir($thumbDir)) { mkdir($thumbDir, 0755, true); } - $uuid = \Symfony\Component\Uid\Uuid::v7()->toRfc4122(); - $filename = $uuid.'.jpg'; - $fullPath = $thumbDir.'/'.$filename; - $saved = imagejpeg($thumb, $fullPath, self::THUMB_QUALITY); imagedestroy($thumb); @@ -232,7 +234,7 @@ private function resizeAndSave(\GdImage $source, Orientation $orientation): ?str return null; } - return 'thumbs/'.$filename; + return $relativePath; } /** diff --git a/tests/Service/ThumbnailServiceRawTest.php b/tests/Service/ThumbnailServiceRawTest.php index f338624e..bce31142 100644 --- a/tests/Service/ThumbnailServiceRawTest.php +++ b/tests/Service/ThumbnailServiceRawTest.php @@ -34,19 +34,16 @@ protected function setUp(): void protected function tearDown(): void { - $thumbsDir = $this->storageDir . '/thumbs'; - if (is_dir($thumbsDir)) { - foreach (glob($thumbsDir . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($thumbsDir); + if (!isset($this->storageDir) || !is_dir($this->storageDir)) { + return; } - foreach (glob($this->storageDir . '/*') ?: [] as $file) { - unlink($file); - } - if (is_dir($this->storageDir)) { - rmdir($this->storageDir); + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->storageDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $f) { + $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname()); } + rmdir($this->storageDir); } /** @@ -104,7 +101,7 @@ public function testGeneratesThumbnailFromEmbeddedPreviewWhenFileIsRaw(): void $thumb = $service->generate($rawPath); $this->assertNotNull($thumb, 'Un RAW avec preview embarquée doit produire une vignette'); - $this->assertStringStartsWith('thumbs/', $thumb); + $this->assertMatchesRegularExpression('#^thumbs/[0-9a-f]{2}/[0-9a-f-]{36}\.jpg$#', $thumb, 'Vignette dans un sous-dossier réparti (#609)'); $this->assertFileExists($this->storageDir . '/' . $thumb); } diff --git a/tests/Service/ThumbnailServiceRealRawTest.php b/tests/Service/ThumbnailServiceRealRawTest.php index 18dbcdfc..e6f2cde1 100644 --- a/tests/Service/ThumbnailServiceRealRawTest.php +++ b/tests/Service/ThumbnailServiceRealRawTest.php @@ -36,16 +36,16 @@ protected function setUp(): void protected function tearDown(): void { - $thumbsDir = ($this->storageDir ?? '') . '/thumbs'; - if (is_dir($thumbsDir)) { - foreach (glob($thumbsDir . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($thumbsDir); + if (!isset($this->storageDir) || !is_dir($this->storageDir)) { + return; } - if (isset($this->storageDir) && is_dir($this->storageDir)) { - rmdir($this->storageDir); + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->storageDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $f) { + $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname()); } + rmdir($this->storageDir); } public function testExtractsFullResolutionPreviewFromRealNef(): void From 8aefd6f7c8563529953a0baf170dbb0f03b408de Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 2 Oct 2026 13:58:48 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20docs(medias):=20documente=20?= =?UTF-8?q?la=20r=C3=A9partition=20du=20stockage=20en=20sous-dossiers=20(#?= =?UTF-8?q?609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/medias.md | 27 ++++++++++++++++++--------- src/Entity/File.php | 2 +- src/Entity/Media.php | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.claude/medias.md b/.claude/medias.md index 52c89cb3..4338be52 100644 --- a/.claude/medias.md +++ b/.claude/medias.md @@ -11,22 +11,30 @@ Une photo uploadée produit jusqu'à trois fichiers distincts. Les confondre est ```text DSC_0190.NEF (52,5 Mo) │ - ├── File (BDD) ──── path: "2026/07/xxx.NEF" → var/storage/2026/07/xxx.NEF + ├── File (BDD) ──── path: "ef/xxx.NEF" → var/storage/ef/xxx.NEF │ ▲ │ │ OneToOne │ │ - └── Media (BDD) ─── thumbnailPath: "thumbs/019f...jpg" + └── Media (BDD) ─── thumbnailPath: "thumbs/ef/019f...ef.jpg" │ - └→ var/storage/thumbs/019f...jpg + └→ var/storage/thumbs/ef/019f...ef.jpg ``` | Artefact | Emplacement | Généré | Poids (ex. NEF) | |----------|-------------|--------|-----------------| -| Fichier original | `var/storage/{année}/{mois}/` | à l'upload, conservé tel quel | 52,5 Mo | -| Vignette (galerie) | `var/storage/thumbs/{uuid}.jpg` | **une fois**, pipeline async | 43 Ko | -| Preview (plein écran) | `var/storage/previews/{hash}.jpg` | **1ʳᵉ vue**, puis cachée | 1,05 Mo | +| Fichier original | `var/storage/{shard}/` | à l'upload, conservé tel quel | 52,5 Mo | +| Vignette (galerie) | `var/storage/thumbs/{shard}/{uuid}.jpg` | **une fois**, pipeline async | 43 Ko | +| Preview (plein écran) | `var/storage/previews/{xx}/{hash}.jpg` | **1ʳᵉ vue**, puis cachée | 1,05 Mo | -Seule la vignette est référencée en base (`Media::$thumbnailPath`). La preview ne l'est pas : son nom est dérivé du chemin source (voir *Cache des previews*). +Seule la vignette est référencée en base (`Media::$thumbnailPath`). La preview ne l'est pas : son nom est dérivé du **nom** du fichier source (voir *Cache des previews*). + +### Répartition en sous-dossiers (#609) + +L'hébergeur (o2switch) interdit plus de **20 000 fichiers dans un même répertoire**. Aucun dossier de stockage ne doit donc grossir avec le volume importé : `StorageShard` range chaque fichier dans `/`, où `shard` = les **2 derniers caractères hexadécimaux** du nom (partie aléatoire de l'UUID v7 ; son début est un horodatage commun à tout un import). 256 sous-dossiers, ~400 fichiers chacun pour 100 000 photos, sans inode supplémentaire par photo. Pas de date dans le chemin (256 dossiers par mois seraient presque vides sur les petites instances). + +- Le chemin est stocké tel quel en base : les anciens fichiers (`AAAA/MM/.`, `thumbs/.jpg`) restent lisibles sans rien changer. +- `StorageService::getAbsolutePath()` et `delete()` retombent sur le chemin réparti si l'ancien n'existe plus (fichier déplacé alors qu'une entité en mémoire porte encore l'ancien chemin). +- Retour arrière du code possible : l'ancien code relit un fichier réparti, puisqu'il lit le chemin en base. --- @@ -133,11 +141,12 @@ Préparer une preview coûte ~1 s (décodage, rotation, rééchantillonnage de 4 ### Le nom du cache est dérivé, pas stocké -`hash('xxh128', $cheminSource)` plutôt qu'une colonne en base : +`hash('xxh128', basename($cheminSource))` (#609 : le **nom**, stable quand l'original est déplacé, et non le chemin complet) rangé dans `previews/<2 premiers caractères du hash>/`, plutôt qu'une colonne en base : - pas de migration Doctrine ni de champ à maintenir ; - le cache reste **jetable** — `rm -rf var/storage/previews/` et tout se régénère ; -- le hash aplatit le chemin (qui contient des slashes) en un nom de fichier plat, ce qui écarte au passage toute traversée de répertoire. +- le hash aplatit le nom en un nom de fichier sûr, ce qui écarte au passage toute traversée de répertoire ; +- les anciennes previews à plat (haché sur le chemin complet) sont inatteignables : `RawPreviewCache::purgeLegacyFlatEntries()` les supprime, elles se régénèrent à la demande. ### Invalidation diff --git a/src/Entity/File.php b/src/Entity/File.php index ee4da77c..939b5ca8 100644 --- a/src/Entity/File.php +++ b/src/Entity/File.php @@ -43,7 +43,7 @@ class File #[ORM\Column] private int $size; - /** Chemin relatif dans var/storage/ (ex: "2026/02/uuid.pdf") */ + /** Chemin relatif dans var/storage/ (ex: "ef/uuid.pdf" depuis #609, ancien format "2026/02/uuid.pdf" encore lisible) */ #[ORM\Column(length: 1024)] private string $path; diff --git a/src/Entity/Media.php b/src/Entity/Media.php index c1f8383a..267db71d 100644 --- a/src/Entity/Media.php +++ b/src/Entity/Media.php @@ -22,7 +22,7 @@ * - Tous les champs sont nullable : l'extraction EXIF peut échouer partiellement * (photo sans GPS, image sans EXIF, GD absent pour le thumbnail…). * - Immuable après création : re-traiter = DELETE Media + re-dispatch. - * - thumbnailPath : chemin relatif dans var/storage/thumbs/{uuid}.jpg + * - thumbnailPath : chemin relatif dans var/storage/thumbs/{shard}/{uuid}.jpg (ancien format thumbs/{uuid}.jpg lisible, #609) */ class Media {