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; }