Skip to content

Gestion de fichiers

Plusieurs applications web permettront de téléverser ou télécharger des fichiers. Notez que ceci ne fait pas partie des opérations CRUD sur les données, mais bien d'un échange de données.

Dépendences requises

Bien qu'il ne soit pas nécessaire d'ajouter de dépendances si on veut simplement permettre le téléversement et téléchargement de fichiers, nous ajouterons une dépendance à Apache Tika, un analyseur de documents. Ceci nous aidera à valider le type de fichiers lors du téléversement.

<!-- Tika, pour valider les fichiers -->
<dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-core</artifactId>
    <version>3.2.3</version>
</dependency>
<dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-parsers-standard-package</artifactId>
    <version>3.2.3</version>
</dependency>

Structure générale

Pour permette à l'API de gérer les fichiers, nous allons créer les structures suivantes:

  • Une entité pour conserver les méta données du fichier dans la base de données (avec son repository)
  • Un contrôleur pour gérer les interactions avec l'utilisateur
  • Un service qui s'occupe des opérations de lecture et d'écriture
  • Un service qui lie le contrôleur aux opérations de lecture et d'écriture.

Configuration

En plus des structures mentionnées ci-dessus, on peut créer une propriété de configuration pour mieux gérer les propriétés des fichiers dans l'application.

  • Créer un enregistrement (record) nommé FileStorageProperties
  • Lui donner les propriétés basePath et allowedMimeTypes
@ConfigurationProperties(prefix = "app.file-storage")
public record FileStorageProperties(String basePath, Set<String> allowedMimeTypes) {
    public FileStorageProperties() {
        this(
        "votre/chemin/stockage",
                Set.of(
                        "image/jpeg",
                        "image/png",
                        "image/webp",
                        "image/gif",
                        "text/csv"                        
                )
        );
    }
}

// Dans le fichier principal de l'application
@SpringBootApplication
// Ne pas oublier d'activer la propriété de configuration
@EnableConfigurationProperties({FileStorageProperties.class})
public class DemoApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApiApplication.class, args);
    }

}

Dans l'exemple ci dessus, je crée également un constructeur et lui donne des valeurs par défaut. Ces propriétés peuvent être configurées dans le fichier application.properties.Il suffit d'ajouter la configuration des propriétés

  • app.file-storage.base-path=
  • app.file-storage.allowed-mime-types=

Pour une liste de type MIME, consulter la documentation suivante : MDN - Liste des types MIME communs

La classe de méta données

Le but de cette classe est de conserver des informations sur les fichiers téléversés, afin qu'on puisse facilement les retrouver et qu'on puisse afficher les informations de base aux utilisateurs en cas de besoin.

Voici une configuration minimale des méta données que l'on voudrait conserver.

@Entity
@Table(name = "file_metadata")
public class FileMetaData {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    String nomOriginal;
    String nomStocke;
    String mimeType;    
}
  • Avec ces informations, on peut retrouver facilement un fichier avec son id.
  • On ne se fie pas au nom original pour retrouver un fichier, c'est sous le contrôle de l'uitlisateur
  • Le nom stocké comprendra le chemin d'accès complet. Le nom du fichier devrait également différer du nom original.
  • Le mimeType nous permettra d'indiquer à l'utilisateur le type de fichier tout simplement.
  • Lors de la sauvegarde d'un fichier, ces données seront extraites de celui-ci et enregistrées dans la base de données. Cela nous sert en quelque sorte d'index pour retrouver le fichier.

Le service des opérations de lecture et d'écriture

Ce service aura besoin de deux propriétés globales qu'on définit dans le constructeur - Une instance de FileStorageProperties - Le chemin de base de type Path

@Service
public class FileStorageService {
    private final FileStorageProperties properties;
    private final Path rootPath;

    public FileStorageService(FileStorageProperties properties) {
        this.properties = properties;
        this.rootPath = Paths.get(properties.basePath());
    }
}

Ce service fera simplement les opérations de lecture et d'écriture. On y mettra donc deux fonctions, une pour stocker un fichier et l'autre pour récupérer un fichier.

// Extrait de FileStorageService

public String storeFile(InputStream inputStream, String originalFilename) throws IOException {
    // Création d'un dossier avec la date du jour. Ce dossier sera créé à la racine fournie dans les propriétés
    LocalDate today = LocalDate.now();
    Path dateDirectory = rootPath.resolve(today.getYear() + "-" + String.format("%02d", today.getMonthValue())  + " " + String.format("%02d", today.getDayOfMonth()));
    Files.createDirectories(dateDirectory);

    String ext = getFileExtension(originalFilename);
    // Création d'un nom unique pour le fichier
    String filename = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
    Path path = dateDirectory.resolve(filename);

    // Sauvegarde du fichier
    try (OutputStream outputStream = Files.newOutputStream(path,  StandardOpenOption.CREATE_NEW)) {
        StreamUtils.copy(inputStream, outputStream);
    }

    // On retourner le chemin d'accès pour pouvoir retrouver le fichier.
    return rootPath.relativize(path).toString();
}

private String getFileExtension(String filename) {
    int lastDot = filename.lastIndexOf(".");
    return lastDot == -1 ? "" : filename.substring(lastDot + 1);
}
// Extrait de FileStorageService

public Resource getFileResource(String storedName) throws IOException {
    Path path = rootPath.resolve(storedName).normalize().toAbsolutePath();
    Path normalizedRootPath = rootPath.normalize().toAbsolutePath();

    // Confirmation que l'utilisateur n'essaie pas de parcourir le systèmede fichiers
    if(!path.startsWith(normalizedRootPath)) {
        throw new SecurityException("Access denied");
    }

    if(!Files.exists(path)) {
        throw new FileNotFoundException("File not found");
    }

    return new UrlResource(path.toUri());
}

Le service utilisé par le contrôleur

Ce service comprendra les méthodes qui sont appelées par la contrôleur. Il interagira également avec le service de lecture et d'écriture pour envoyer les fichiers vers le système de fichiers ou les récupérer de ce dernier pour les envoyer à l'utilisateur.

@Service
public class FileService {
    private final FileStorageProperties properties;
    private final FileMetaDataRepository repository;
    private final FileStorageService service;

    public FileService(FileStorageProperties properties, FileMetaDataRepository repository, FileStorageService service) {
        this.properties = properties;
        this.repository = repository;
        this.service = service;
    }
}

Ce service comprendra les méthodes pour traiter un fichier téléversé avant d'en faire la sauvegarde ainsi que les méthodes pour récupérer l'information des fichiers et les fichiers en tant que tel.

// Extrait de FileService

public FileMetaData uploadFile(MultipartFile file) throws IOException {
    // Valider le fichier pour raison de sécurité - voir méthode ci-dessous
    validateFile(file);
    String storagePath;

    // Tenter de sauvegarder le fichier
    try(InputStream inputStream = file.getInputStream()) {
        storagePath = service.storeFile(inputStream, file.getOriginalFilename());
    }

    // Si le fichier est sauvegardé on crée les méta données et les sauvegarde dans la base de données.
    FileMetaData fileMetaData = FileMetaData
            .builder()
            .nomOriginal(file.getOriginalFilename())
            .nomStocke(storagePath)
            .mimeType(file.getContentType())
            .build();

    return repository.save(fileMetaData);
}

private void validateFile(MultipartFile file) throws IOException {
    // Création d'une instance de Tika pour valider le type du fichier
    Tika tika = new Tika();

    if(file.isEmpty()) {
        throw new IllegalArgumentException("File is empty");
    }

    String mimeType = file.getContentType();
    String magicMimeType = tika.detect(file.getInputStream());

    // Vérifier si on se fait mentir par l'utilisateur sur le type de fichier (grrrrr méchant!)
    if(!Objects.equals(magicMimeType, mimeType)) {
        throw new SecurityException("Un méchant menteur tente de téléverser un fichier avec un type modifié");
    }

    if (magicMimeType == null || !properties.allowedMimeTypes().contains(mimeType)) {
        throw new IllegalArgumentException("Mime type invalide");
    }
}
// Extrait de FileService

public Resource getFile(Long fileId) throws IOException {
    FileMetaData metadata = repository.getById(fileId);
    return service.getFileResource(metadata.getNomStocke());
}

public FileMetaData getFileMetadata(Long fileId) throws IOException {
    return repository.findById(fileId).orElse(null);
}

Le contrôleur

Finalement le contrôleur permet à l'utilisateur d'ajouter et récupérer des fichiers

@Controller
@RequestMapping("/files")
public class FileController {
    private final FileService fileService;

    public FileController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping
    public ResponseEntity<?> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
        try {
            FileMetaData metadata = fileService.uploadFile(file);

            return ResponseEntity.ok().body(metadata);
        } catch (IOException ex) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        } catch (IllegalArgumentException ex) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
    }

    @GetMapping("/{fileId}")
    public ResponseEntity<Resource> getFile(@PathVariable Long fileId) {
        try {
            FileMetaData fileMetaData = fileService.getFileMetadata(fileId);
            Resource resource = fileService.getFile(fileId);
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileMetaData.getNomOriginal() + "\"")
                    .contentType(MediaType.parseMediaType(fileMetaData.getMimeType()))
                    .body(resource);
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

Tester avec cURL et Postman

N'oubliez pas d'ajouter le jeton d'authentification si l'action demande d'être authentifié!

Postman

Pour tester le téléversement avec Postman, il suffit d'ajouter un fichier dans le corps de la requête.
- Sous l'onglet Body, sélectionner form-data - Nommer la clé file (pour correspondre avec les notes) et ajouter le fichier désiré.

Téléversement avec postman

Pour tester la récupération dans Postman, il suffit de choisir l'option Send and download lors de la transmission de la requête.

Télécharger avec Postman

cURL

Curl nous permet également de tester le tout en ligne de commande

Pour tester le téléversement avec cURL, il suffit de spécifier qu'on ajoute un fichier avec l'option -F.

curl http://localhost:8080/files -H "Authorization: Bearer {JWT}" -F "file=@/chemin/vers/fichier"

Pour teste le réléchargement avec cURL, on ajoutera les options -OJ à la requête.

  • -O Récupère le résultat de la requête dans un fichier
  • -J Récupérer le nom de fichier de l'en-tête Content-Disposition si elle est fournie par le serveur
curl -OJ http://localhost:8080/files/1 -H "Authorization: Bearer {JWT}"