Upload de gros fichiers dans un projet.

Avec un serveur PHP, on le sait, la limite de chargement des fichiers est fixée par le php.ini. Pour charger des fichiers plus gros que cette limite sans augmenter celle ci, il semble qu'il n'y ait pas d'autre choix que de découper le fichier en morceaux, de les envoyer 1 par 1 sur le serveur, et de les assembler 1 par 1 sur le serveur pour reconstituer le fichier d'origine.

Le découpage est fait côté client, l'assemblage, côté serveur. Et cela tient finalement en assez peu de code. Le javascript récupère le fichier et le découpe. Il envoie en ajax les morceaux 1 par 1.

le code présenté ici est largement réalisé à partir de https://zestedesavoir.com/billets/2372/uploader-un-fichier-volumineux-par-le-web/#2-mais-en-fait-des-bibliotheques-existent

Côté JS la découpe du fichier est réalisée essentiellement par :

let blob      = this.file.slice(start, nextSlice);

Côté PHP, ces morceaux s'empilent du premier au dernier et finissent par reformer sur le serveur le fichier d'origine. C'est le rôle de

file_put_contents($this->folderPath . '/' . $fileName, $fileData, FILE_APPEND)

Le code

J'ai implémenté ce code basiquement dans un projet symfony en utilisant un contrôleur JS avec stimulus.

N.B. : ce projet utilise bootstrap

html

L'input de type file et le form nécessitent un ID. Le controller JS est implémenté sur le bouton.

<form id="big-upload-form">
                <div class="mb-3">
                    <div id="form-errors" class="mt-2 mb-2 d-none">
                        <span class="badge bg-danger text-white">erreur</span>
                        <span id="error" class="text-danger"></span>
                    </div>
                    <input class="form-control" type="file" id="big-file-input" required="required">
                    <div class="col col-12 mt-1">
                        <div class="progress">
                            <div class="progress-bar" role="progressbar" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100" id="big-upload-progress"></div>
                        </div>
                    </div>
                </div>
                <div class="row">
                    <div class="col col-6">
                        <input type="submit" class="btn btn-primary text-white" value="Charger" id="submit-button"
                               data-controller="big-upload"
                               data-action="click->big-upload#upload"
                               data-size="5" // optionnal - size of chunks in Mo
                        />
                    </div>
                </div>
            </form>

Controller JS

Si vous n'utilisez pas stimulus et que vous n'êtes pas dans un projet symfony, il serait assez simple de transformer le code du contrôleur ci-dessous en une classe JS autonome.

import { Controller } from 'stimulus';

export default class extends Controller {
    connect() {
        this.form        = document.querySelector('#big-upload-form')
        this.reader      = null;
        this.file        = null;
        this.slice_size  = this.element.dataset.size ? this.element.dataset.size * 1000 * 1024 : 1000 * 1024 // see optionnal line at the end of html
        this.input       = this.form.querySelector('#big-file-input')
        this.progressBar = this.form.querySelector('.progress-bar')
        this.step        = 0;

        this.divError = this.form.querySelector('#form-errors')
        this.error = this.form.querySelector('#error')
    }

    startUpload(e) {
        e.preventDefault()
        this.reader = new FileReader();
        this.file   = this.input.files[0];
        if(this.file) {
            this.uploadFile(0);
        } else {
            this.showError('Vous devez spécifier un fichier')
        }
    }

    upload(e) {
        e.preventDefault
        e.stopPropagation()
        this.resetProgressBar()
        this.startUpload(e)
    }

    uploadFile(start) {
        let nextSlice = start + this.slice_size + 1;
        let blob      = this.file.slice(start, nextSlice); // on ne voudra lire qu'un segment du fichier
        if(this.step === 0) {
            this.hideErrors()
        }

        this.reader.onloadend = (event) => { // fonction à exécuter lorsque le segment a fini d'être lu
            if (event.target.readyState !== FileReader.DONE) {
                return;
            }
            fetch('/uploadbigfile', {
                method: "POST",
                body: JSON.stringify({
                    file_data: event.target.result,
                    file_name: this.file.name,
                    step: this.step
                })
            }).then(response => {
                if(response.status === 200) {
                    let sizeDone    = start + this.slice_size
                    let percentDone = Math.floor((sizeDone / this.file.size) * 100)

                    if (nextSlice < this.file.size) {
                        this.progress(percentDone)
                        this.uploadFile(nextSlice) // s'il reste à lire, on appelle récursivement la fonction
                        this.step++
                    } else {
                        this.success()
                        this.input.value = null
                        console.log(this.step)
                    }
                }
            }).catch((jqXHR, textStatus, errorThrown) => {
                console.error(jqXHR, textStatus, errorThrown)
            })
        };

        this.reader.readAsDataURL(blob); // lecture du segment
    }

    resetProgressBar() {
        this.progressBar.innerHTML   = ''
        this.progressBar.style.width = '0%'
    }

    progress(percentDone) {
        this.progressBar.innerHTML   = percentDone + '%'
        this.progressBar.style.width = percentDone + '%'
    }

    showError(text) {
        this.error.innerText = text
        this.divError.classList.remove('d-none')
    }

    hideErrors() {
        if(!this.divError.classList.contains('d-none')) {
            this.divError.classList.add('d-none')
        }
    }

    success() {
        this.progressBar.innerHTML   = 'Téléchargement effectué avec succès!'
        this.progressBar.style.width = '100%'
    }

}

Côté PHP

Un service

<?php

namespace App\Service\Upload;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;

class BigUploader
{

    private KernelInterface $kernel;
    private string $folderPath;

    public function __construct(KernelInterface $kernel)
    {
        $this->kernel = $kernel;
    }

    public function setFolderPath(string $folderPath): void
    {
        $this->folderPath = $this->kernel->getProjectDir() . '/' . $folderPath;
    }

    private function checkDir(): void
    {
        if(!is_dir($this->folderPath)) {
            mkdir($this->folderPath);
        }
    }

    private function checkFile(array $data): void
    {
        if(is_file($this->folderPath . '/' . $data['file_name']) && $data['step'] === 0) {
            unlink($this->folderPath . '/' . $data['file_name']);
        }
    }

    public function upload(array $data): int
    {
        $fileName = $data['file_name'];

        $this->checkDir();
        $this->checkFile($data);

        $fileData = $this->decodeChunk($data['file_data']);

        if (!$fileData) {
            return Response::HTTP_NO_CONTENT;
        }

        if(!file_put_contents($this->folderPath . '/' . $fileName, $fileData, FILE_APPEND)) {
            return Response::HTTP_INTERNAL_SERVER_ERROR;
        }

        return Response::HTTP_OK;
    }

    private function decodeChunk(string $data): ?string
    {
        $data = explode(';base64,', $data);

        if (!is_array($data) || !isset($data[1])) {
            return null;
        }

        $data = base64_decode($data[1]);
        if (!$data) {
            return null;
        }

        return $data;
    }
}

Et un controller

Pour cet essai, j'ai choisi de ne pas utiliser les formulaires symfony. Dans le cadre de ceux ci, il serait alors plus intéressant d'utiliser ceux-ci plutot que $request.

/**
     * @Route("/upload", name="upload", methods={"POST"})
     */
    public function uploadBigFile(KernelInterface $kernel, BigUploader $bigUploader, Request $request)
    {
        $data = json_decode($request->getContent(), true);

        $response = new JsonResponse();

        $bigUploader->setFolderPath('Storage/bigfiles'); // base folder is project root directory

        $response->setStatusCode($bigUploader->upload($data));

        return $response;
    }

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *