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
27 changes: 18 additions & 9 deletions .claude/medias.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<shard>/`, 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/<uuid>.<ext>`, `thumbs/<uuid>.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.

---

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/Entity/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/Entity/Media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
8 changes: 8 additions & 0 deletions src/Interface/Media/RawPreviewCacheInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
24 changes: 18 additions & 6 deletions src/Service/File/StorageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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,
];
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down
71 changes: 71 additions & 0 deletions src/Service/File/StorageShard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace App\Service\File;

/**
* Répartition des fichiers du stockage en sous-dossiers (#609).
*
* Rôle : l'hébergement mutualisé o2switch interdit plus de 20 000 fichiers
* dans un même répertoire. Un import volumineux rangé à plat (un seul mois,
* un seul dossier de vignettes) dépasserait cette limite.
*
* Choix :
* - Le shard est formé des 2 derniers caractères hexadécimaux du nom (hors
* extension) : la FIN d'un UUID v7 est aléatoire, alors que son début est un
* horodatage commun à tout un import. 256 sous-dossiers, ~400 fichiers
* chacun pour 100 000 photos.
* - Le shard se déduit uniquement du nom de fichier : déterministe, donc la
* commande de migration est idempotente sans table d'état.
* - Pas de date dans le chemin : opaque en base, elle créerait jusqu'à 256
* dossiers par mois, presque vides sur les petites instances (inodes).
* - Un nom qui ne se termine pas par deux caractères hexadécimaux (fixtures,
* anciens noms) retombe sur un hash stable.
*/
final class StorageShard
{
private const LEGACY_ORIGINAL_PATTERN = '#^\d{4}/\d{2}/[^/]+$#';
private const LEGACY_THUMBNAIL_PATTERN = '#^thumbs/[^/]+$#';

public static function forFilename(string $nameOrPath): string
{
$stem = pathinfo($nameOrPath, PATHINFO_FILENAME);

if (preg_match('/([0-9a-fA-F]{2})$/', $stem, $m) === 1) {
return strtolower($m[1]);
}

return substr(hash('xxh128', $stem), 0, 2);
}

/** Chemin relatif d'un original : `<shard>/<nom>`. */
public static function originalPath(string $filename): string
{
return self::forFilename($filename).'/'.$filename;
}

/** Chemin relatif d'une vignette : `thumbs/<shard>/<nom>`. */
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/<nom>` ou `thumbs/<nom>`). 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;
}
}
38 changes: 32 additions & 6 deletions src/Service/Media/RawPreviewCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<uuid>.<ext>`, 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>/`
* (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.
Expand Down Expand Up @@ -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;
Expand All @@ -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';
}
Expand Down
18 changes: 10 additions & 8 deletions src/Service/Media/ThumbnailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -216,23 +217,24 @@ 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);

if (!$saved) {
return null;
}

return 'thumbs/'.$filename;
return $relativePath;
}

/**
Expand Down
Loading
Loading