diff options
| author | polo <ordipolo@gmx.fr> | 2026-01-12 17:16:41 +0100 |
|---|---|---|
| committer | polo <ordipolo@gmx.fr> | 2026-01-12 17:16:41 +0100 |
| commit | 505cab0210e83bf62085b665abad0698a8d31352 (patch) | |
| tree | 6e66d20d2041bae3f800ee99fdde1957c7bb9f51 | |
| parent | dfed030ca6edf832397d273fbb0ad4b0b5d35877 (diff) | |
| download | cms-505cab0210e83bf62085b665abad0698a8d31352.tar.gz cms-505cab0210e83bf62085b665abad0698a8d31352.tar.bz2 cms-505cab0210e83bf62085b665abad0698a8d31352.zip | |
upload de fichiers (JS + contrôleur + htaccess), collage de fichiers dans l'éditeur (JS), amélioration utilisation de Imagick (ImageUploadController)
| -rw-r--r-- | public/js/tinymce.js | 261 | ||||
| -rw-r--r-- | public/user_data/media/.htaccess | 10 | ||||
| -rw-r--r-- | src/Captcha.php | 6 | ||||
| -rw-r--r-- | src/controller/FileUploadController.php | 67 | ||||
| -rw-r--r-- | src/controller/HeadFootController.php | 5 | ||||
| -rw-r--r-- | src/controller/ImageUploadController.php | 186 | ||||
| -rw-r--r-- | src/router.php | 7 |
7 files changed, 392 insertions, 150 deletions
diff --git a/public/js/tinymce.js b/public/js/tinymce.js index a187189..7b59506 100644 --- a/public/js/tinymce.js +++ b/public/js/tinymce.js | |||
| @@ -72,6 +72,9 @@ function deleteArticle(id){ | |||
| 72 | 72 | ||
| 73 | class Editor | 73 | class Editor |
| 74 | { | 74 | { |
| 75 | extensions_white_list = ['pdf', 'rtf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp']; | ||
| 76 | // = $extensions_white_list côté PHP | ||
| 77 | |||
| 75 | constructor(id){ | 78 | constructor(id){ |
| 76 | this.id = id; | 79 | this.id = id; |
| 77 | this.article = document.getElementById(this.id); | 80 | this.article = document.getElementById(this.id); |
| @@ -108,7 +111,7 @@ class Editor | |||
| 108 | tinymce.init({ | 111 | tinymce.init({ |
| 109 | selector: `[id="${this.id}"]`, // écrire [id="246"] au lieu de #246 parce que l'id commence par un chiffre | 112 | selector: `[id="${this.id}"]`, // écrire [id="246"] au lieu de #246 parce que l'id commence par un chiffre |
| 110 | language: 'fr_FR', | 113 | language: 'fr_FR', |
| 111 | language_url: 'js/tinymce-langs/fr_FR.js', // téléchargement ici: https://cdn.jsdelivr.net/npm/tinymce-lang/langs/fr_FR.min.js | 114 | language_url: 'js/tinymce-langs/fr_FR.js', |
| 112 | license_key: 'gpl', | 115 | license_key: 'gpl', |
| 113 | branding: false, | 116 | branding: false, |
| 114 | plugins: 'lists link autolink table image media autoresize help', | 117 | plugins: 'lists link autolink table image media autoresize help', |
| @@ -116,6 +119,13 @@ class Editor | |||
| 116 | menubar: false, | 119 | menubar: false, |
| 117 | toolbar_mode: 'wrap', | 120 | toolbar_mode: 'wrap', |
| 118 | statusbar: false, | 121 | statusbar: false, |
| 122 | link_title: false, // supprime le champ compliqué "titre" (apparaît au survol du lien) dans la fenêtre "link" | ||
| 123 | /*link_attributes_postprocess: (attrs) => { // modifier les attributs des liens créés | ||
| 124 | console.log(attrs); | ||
| 125 | if (attrs.rel) { | ||
| 126 | attrs.rel += 'noreferrer'; // cacher la page d'où on vient | ||
| 127 | } | ||
| 128 | },*/ | ||
| 119 | // les fonctions fléchées permettent de garder le contexte (= this) | 129 | // les fonctions fléchées permettent de garder le contexte (= this) |
| 120 | setup: (editor) => { | 130 | setup: (editor) => { |
| 121 | editor.on('init', () => { | 131 | editor.on('init', () => { |
| @@ -141,55 +151,61 @@ class Editor | |||
| 141 | } | 151 | } |
| 142 | }); | 152 | }); |
| 143 | let skipPastePreProcess = false; | 153 | let skipPastePreProcess = false; |
| 144 | editor.on('Paste', function (e){ // déclenchement AVANT PastePreProcess et quelque que soit le contenu collé | 154 | editor.on('Paste', (e) => { // déclenchement AVANT PastePreProcess et quelque que soit le contenu collé |
| 145 | const clipboardData = (e.clipboardData || e.originalEvent.clipboardData); | 155 | if(!e.clipboardData){ // e.clipboardData: DataTransfer |
| 146 | if(!clipboardData){ | ||
| 147 | return; | 156 | return; |
| 148 | } | 157 | } |
| 149 | const items = clipboardData.items; | 158 | const items = e.clipboardData.items; // base64 |
| 150 | let foundImage = false; | 159 | const files = e.clipboardData.files; // explorateur de fichiers |
| 151 | 160 | let found_file = false; | |
| 152 | for(let i = 0; i < items.length; i++){ | 161 | |
| 153 | let item = items[i]; | 162 | // données dans files |
| 154 | 163 | if(files && files.length > 0){ // noter que files peut être vide, alors que items non | |
| 155 | if(item.type.indexOf('image') !== -1){ // test type MIME contenant image | 164 | for(let i = 0; i < files.length; i++){ |
| 156 | foundImage = true; | 165 | let file = files[i]; |
| 157 | 166 | ||
| 158 | const file = item.getAsFile(); // presse-papier => fichier lisible | 167 | if(this.extensions_white_list.includes(file.name.split('.').pop()?.toLowerCase() || '')){ |
| 159 | const reader = new FileReader(); | 168 | found_file = true; |
| 160 | 169 | this.uploadDocument(file, editor); | |
| 161 | reader.onload = function (event){ // fonction exécutée lorsque reader.readAsDataURL(file) est terminée | 170 | } |
| 162 | const base64Data = event.target.result; // données de l'image | 171 | else if(file.type.indexOf('image') !== -1){ |
| 163 | 172 | found_file = true; | |
| 164 | fetch('index.php?action=upload_image_base64', { | 173 | this.uploadImageBase64(file, editor); |
| 165 | method: 'POST', | 174 | } |
| 166 | headers: { 'Content-Type': 'application/json' }, | 175 | } |
| 167 | body: JSON.stringify({ image_base64: base64Data }) | 176 | } |
| 168 | }) | 177 | // données dans items |
| 169 | .then(response => response.json()) | 178 | else{ // les images collées depuis l'explorateur sont aussi dans items, or elles sont déjà gérées plus haut |
| 170 | .then(data => { | 179 | for(let i = 0; i < items.length; i++){ |
| 171 | if(data.location){ | 180 | let item = items[i]; |
| 172 | editor.insertContent('<img src="' + data.location + '">'); | 181 | |
| 173 | } | 182 | if(item.type.indexOf('image') !== -1){ // test type MIME contenant image |
| 174 | }) | 183 | found_file = true; |
| 175 | .catch(error => { | 184 | const file = item.getAsFile(); // presse-papier => fichier lisible |
| 176 | console.error('Erreur lors de l’upload de l’image base64 :', error); | 185 | if(file){ |
| 177 | }); | 186 | this.uploadImageBase64(file, editor); |
| 178 | }; | 187 | } |
| 179 | reader.readAsDataURL(file); // lecture asynchrone du fichier | 188 | else{ |
| 189 | console.error('fichier invalide'); | ||
| 190 | } | ||
| 191 | } | ||
| 180 | } | 192 | } |
| 181 | } | 193 | } |
| 182 | 194 | ||
| 183 | if(foundImage){ | 195 | if(found_file){ |
| 184 | e.preventDefault(); // supprime le collage automatiue | 196 | e.preventDefault(); // supprime le collage automatiue |
| 185 | skipPastePreProcess = true; // désactiver le PastePreProcess pour ce collage | 197 | skipPastePreProcess = true; // désactiver le PastePreProcess pour ce collage |
| 186 | } | 198 | } |
| 187 | }); | 199 | }); |
| 188 | editor.on('PastePreProcess', function (e){ // déclenchement au collage AVANT insertion dans l'éditeur | 200 | editor.on('PastePreProcess', function (e){ // déclenchement au collage AVANT insertion dans l'éditeur |
| 201 | if(skipPastePreProcess){ | ||
| 202 | skipPastePreProcess = false; // réinitialiser pour la prochaine fois | ||
| 203 | return; // ignorer ce traitement | ||
| 204 | } | ||
| 205 | |||
| 189 | const parser = new DOMParser(); | 206 | const parser = new DOMParser(); |
| 190 | const doc = parser.parseFromString(e.content, 'text/html'); | 207 | const doc = parser.parseFromString(e.content, 'text/html'); |
| 191 | const images = doc.querySelectorAll('img'); | 208 | const images = doc.querySelectorAll('img'); |
| 192 | |||
| 193 | let downloads_in_progress = []; | 209 | let downloads_in_progress = []; |
| 194 | 210 | ||
| 195 | images.forEach(img => { | 211 | images.forEach(img => { |
| @@ -221,7 +237,6 @@ class Editor | |||
| 221 | // une image web ou plus: différer l'insertion dans l'éditeur le temps que le serveur télécharge les images | 237 | // une image web ou plus: différer l'insertion dans l'éditeur le temps que le serveur télécharge les images |
| 222 | if(downloads_in_progress.length > 0){ | 238 | if(downloads_in_progress.length > 0){ |
| 223 | e.preventDefault(); | 239 | e.preventDefault(); |
| 224 | |||
| 225 | Promise.all(downloads_in_progress).then(() => { | 240 | Promise.all(downloads_in_progress).then(() => { |
| 226 | e.content = doc.body.innerHTML; // remplacement du HTML dans l'éditeur par la copie modifiée (doc) | 241 | e.content = doc.body.innerHTML; // remplacement du HTML dans l'éditeur par la copie modifiée (doc) |
| 227 | editor.insertContent(e.content); | 242 | editor.insertContent(e.content); |
| @@ -230,30 +245,58 @@ class Editor | |||
| 230 | else{ | 245 | else{ |
| 231 | e.content = doc.body.innerHTML; // remplacement du HTML dans l'éditeur par la copie modifiée (doc) | 246 | e.content = doc.body.innerHTML; // remplacement du HTML dans l'éditeur par la copie modifiée (doc) |
| 232 | } | 247 | } |
| 233 | }); // fin editor.on('PastePreProcess'... | 248 | }); |
| 234 | }, | 249 | // glisser-déposer de fichiers (sauf images qui sont déjà gérées nativement) |
| 235 | // upload d'image natif de tinymce avec le bouton "Insérer une image" | 250 | editor.on('drop', (e) => { |
| 236 | images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => { | 251 | const data = e.dataTransfer; |
| 237 | const formData = new FormData(); | 252 | if(!data || !data.files || data.files.length === 0){ |
| 238 | formData.append("file", blobInfo.blob()); | 253 | return; // Laisser TinyMCE gérer (texte, images déjà supportées, etc.) |
| 239 | |||
| 240 | fetch("index.php?action=upload_image_tinymce", { | ||
| 241 | method: "POST", | ||
| 242 | body: formData | ||
| 243 | }) | ||
| 244 | .then(response => response.json()) | ||
| 245 | .then(data => { | ||
| 246 | if(data.location) { | ||
| 247 | resolve(data.location); | ||
| 248 | } | 254 | } |
| 249 | else { | 255 | const files = data.files; |
| 250 | reject("Erreur: Chemin d'image invalide"); | 256 | |
| 257 | let has_documents = false; | ||
| 258 | for(let i = 0; i < files.length; i++){ | ||
| 259 | if(this.extensions_white_list.includes(files[i].name.split('.').pop()?.toLowerCase() || '')){ | ||
| 260 | has_documents = true; | ||
| 261 | break; | ||
| 262 | } | ||
| 251 | } | 263 | } |
| 252 | }) | 264 | |
| 253 | .catch(error => { | 265 | if(has_documents){ |
| 254 | reject("Erreur lors de l'upload"); | 266 | e.preventDefault(); |
| 267 | e.stopPropagation(); | ||
| 268 | |||
| 269 | for(let i = 0; i < files.length; i++){ | ||
| 270 | let file = files[i]; | ||
| 271 | |||
| 272 | if(this.extensions_white_list.includes(file.name.split('.').pop()?.toLowerCase() || '')){ | ||
| 273 | this.uploadDocument(file, editor); | ||
| 274 | } | ||
| 275 | else if(file.type.indexOf('image') !== -1){ | ||
| 276 | this.uploadImageBase64(file, editor); | ||
| 277 | } | ||
| 278 | } | ||
| 279 | } | ||
| 280 | // autres cas: tinymce gère tout seul | ||
| 255 | }); | 281 | }); |
| 256 | }), | 282 | }, |
| 283 | // upload d'image avec le bouton "Insérer une image" | ||
| 284 | images_upload_handler: this.images_upload_handler, // = fonction fléchée | ||
| 285 | // upload de documents avec le bouton "insérer un lien" | ||
| 286 | files_upload_handler: this.files_upload_handler, // = fonction fléchée | ||
| 287 | documents_file_types: [ // files_upload_handler a besoin qu'on lui donne tous les types mime | ||
| 288 | { mimeType: 'application/pdf', extensions: [ 'pdf' ] }, | ||
| 289 | { mimeType: 'application/rtf', extensions: [ 'rtf' ] }, | ||
| 290 | { mimeType: 'application/msword', extensions: [ 'doc' ] }, | ||
| 291 | { mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: [ 'docx' ] }, | ||
| 292 | { mimeType: 'application/vnd.ms-excel', extensions: [ 'xls' ] }, | ||
| 293 | { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', extensions: [ 'xlsx' ] }, | ||
| 294 | { mimeType: 'application/vnd.ms-powerpoint', extensions: [ 'ppt' ] }, | ||
| 295 | { mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', extensions: [ 'pptx' ] }, | ||
| 296 | { mimeType: 'application/vnd.oasis.opendocument.text', extensions: [ 'odt' ] }, | ||
| 297 | { mimeType: 'application/vnd.oasis.opendocument.spreadsheet', extensions: [ 'ods' ] }, | ||
| 298 | { mimeType: 'application/vnd.oasis.opendocument.presentation', extensions: [ 'odp' ] } | ||
| 299 | ], | ||
| 257 | image_caption: true | 300 | image_caption: true |
| 258 | }); | 301 | }); |
| 259 | } | 302 | } |
| @@ -385,9 +428,107 @@ class Editor | |||
| 385 | delete editors[this.id]; | 428 | delete editors[this.id]; |
| 386 | console.log(`Editor ${this.id} détruit.`); | 429 | console.log(`Editor ${this.id} détruit.`); |
| 387 | }*/ | 430 | }*/ |
| 388 | } | ||
| 389 | 431 | ||
| 432 | images_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => { | ||
| 433 | const formData = new FormData(); | ||
| 434 | formData.append("file", blobInfo.blob()); | ||
| 435 | |||
| 436 | fetch('index.php?action=upload_image_tinymce', { | ||
| 437 | method: 'POST', | ||
| 438 | body: formData | ||
| 439 | }) | ||
| 440 | .then(response => response.json()) | ||
| 441 | .then(data => { | ||
| 442 | if(data.location){ | ||
| 443 | resolve(data.location); | ||
| 444 | } | ||
| 445 | else{ | ||
| 446 | reject("Erreur: Chemin d'image invalide"); | ||
| 447 | } | ||
| 448 | }) | ||
| 449 | .catch(error => { | ||
| 450 | reject("Erreur lors de l'upload"); | ||
| 451 | }); | ||
| 452 | }); | ||
| 453 | files_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => { // utilisation = bouton "link" (OU drag & drop, et oui) | ||
| 454 | const formData = new FormData(); | ||
| 455 | formData.append("file", blobInfo.blob()); | ||
| 456 | |||
| 457 | fetch('index.php?action=upload_file_tinymce', { | ||
| 458 | method: 'POST', | ||
| 459 | body: formData | ||
| 460 | }) | ||
| 461 | .then(response => response.json()) | ||
| 462 | .then(data => { | ||
| 463 | if(data.location){ | ||
| 464 | // resolve et reject fonctionne avec Promise => type de retour standardisé et évite l'utilistion de callbacks | ||
| 465 | resolve({ | ||
| 466 | url: data.location, | ||
| 467 | fileName: blobInfo.filename(), | ||
| 468 | }); | ||
| 469 | } | ||
| 470 | else{ | ||
| 471 | reject("Erreur: Chemin du fichier invalide"); | ||
| 472 | } | ||
| 473 | }) | ||
| 474 | .catch(error => { | ||
| 475 | reject("Erreur lors de l'upload"); | ||
| 476 | }); | ||
| 477 | }); | ||
| 478 | |||
| 479 | uploadImageBase64(file, editor){ | ||
| 480 | const reader = new FileReader(); | ||
| 390 | 481 | ||
| 482 | reader.onload = function (event){ // fonction exécutée lorsque reader.readAsDataURL(file) est terminée | ||
| 483 | const base64_target = event.target; | ||
| 484 | if(!base64_target || !base64_target.result){ | ||
| 485 | console.error("erreur de lecture du fichier"); | ||
| 486 | return; | ||
| 487 | } | ||
| 488 | |||
| 489 | fetch('index.php?action=upload_image_base64', { | ||
| 490 | method: 'POST', | ||
| 491 | headers: { 'Content-Type': 'application/json' }, | ||
| 492 | body: JSON.stringify({ image_base64: base64_target.result }) | ||
| 493 | }) | ||
| 494 | .then(response => response.json()) | ||
| 495 | .then(data => { | ||
| 496 | if(data.location){ | ||
| 497 | editor.insertContent('<img src="' + data.location + '">'); | ||
| 498 | } | ||
| 499 | }) | ||
| 500 | .catch(error => { | ||
| 501 | console.error('Erreur lors de l’upload de l’image base64 :', error); | ||
| 502 | }); | ||
| 503 | }; | ||
| 504 | reader.readAsDataURL(file); // lecture asynchrone du fichier | ||
| 505 | } | ||
| 506 | uploadDocument(file, editor){ // utilisation = copier-coller de l'explorateur de fichiers | ||
| 507 | const formData = new FormData(); | ||
| 508 | formData.append("file", file); | ||
| 509 | |||
| 510 | fetch('index.php?action=upload_file_tinymce', { | ||
| 511 | method: 'POST', | ||
| 512 | body: formData | ||
| 513 | }) | ||
| 514 | .then(response => response.json()) | ||
| 515 | .then(data => { | ||
| 516 | if(data.location){ | ||
| 517 | // créer le lien <a> | ||
| 518 | const file_name = file.name; | ||
| 519 | const extension = file_name.split('.').pop()?.toLowerCase() || ''; | ||
| 520 | const target = extension === 'pdf' ? 'target="_blank"' : ''; // PDF = page | ||
| 521 | editor.insertContent(`<a href="${data.location}" ${target} title="${file_name}">[${extension}] ${file_name}</a>`); | ||
| 522 | } | ||
| 523 | else { | ||
| 524 | console.error("Erreur: Chemin du fichier invalide"); | ||
| 525 | } | ||
| 526 | }) | ||
| 527 | .catch(error => { | ||
| 528 | console.error("Erreur lors de l'upload du document :", error); | ||
| 529 | }); | ||
| 530 | } | ||
| 531 | } | ||
| 391 | 532 | ||
| 392 | 533 | ||
| 393 | 534 | ||
diff --git a/public/user_data/media/.htaccess b/public/user_data/media/.htaccess new file mode 100644 index 0000000..4494297 --- /dev/null +++ b/public/user_data/media/.htaccess | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | # désactiver l'interpréteur PHP | ||
| 2 | php_flag engine off | ||
| 3 | |||
| 4 | # exploration des dossiers | ||
| 5 | Options -Indexes | ||
| 6 | |||
| 7 | # interdire l'accès directaux fichiers .htaccess et .htpasswd | ||
| 8 | <FilesMatch "^\.(htaccess|htpasswd)$"> | ||
| 9 | Require all denied | ||
| 10 | </FilesMatch> | ||
diff --git a/src/Captcha.php b/src/Captcha.php index f60031a..a0c7a54 100644 --- a/src/Captcha.php +++ b/src/Captcha.php | |||
| @@ -10,12 +10,10 @@ class Captcha | |||
| 10 | { | 10 | { |
| 11 | private int $a; | 11 | private int $a; |
| 12 | private int $b; | 12 | private int $b; |
| 13 | private int $solution; | ||
| 14 | 13 | ||
| 15 | public function __construct(){ | 14 | public function __construct(){ |
| 16 | $this->a = rand(2, 9); | 15 | $this->a = rand(2, 9); |
| 17 | $this->b = rand(2, 9); | 16 | $this->b = rand(2, 9); |
| 18 | $this->solution = $this->a * $this->b; | ||
| 19 | } | 17 | } |
| 20 | 18 | ||
| 21 | public function getA(): string | 19 | public function getA(): string |
| @@ -28,7 +26,7 @@ class Captcha | |||
| 28 | } | 26 | } |
| 29 | public function getSolution(): int | 27 | public function getSolution(): int |
| 30 | { | 28 | { |
| 31 | return $this->solution; | 29 | return ($this->a * $this->b); |
| 32 | } | 30 | } |
| 33 | 31 | ||
| 34 | private function toLettersFrench(int $number): string | 32 | private function toLettersFrench(int $number): string |
| @@ -45,6 +43,8 @@ class Captcha | |||
| 45 | default => '', // erreur | 43 | default => '', // erreur |
| 46 | }; | 44 | }; |
| 47 | } | 45 | } |
| 46 | |||
| 47 | // (à déplacer dans FormValidation?) | ||
| 48 | static public function controlInput(string $input = '0'): int | 48 | static public function controlInput(string $input = '0'): int |
| 49 | { | 49 | { |
| 50 | // un POST est une chaîne qu'on doit convertir en nombre dans deux conditions: | 50 | // un POST est une chaîne qu'on doit convertir en nombre dans deux conditions: |
diff --git a/src/controller/FileUploadController.php b/src/controller/FileUploadController.php new file mode 100644 index 0000000..f53f5c2 --- /dev/null +++ b/src/controller/FileUploadController.php | |||
| @@ -0,0 +1,67 @@ | |||
| 1 | <?php | ||
| 2 | // src/controller/FileUploadController.php | ||
| 3 | |||
| 4 | declare(strict_types=1); | ||
| 5 | |||
| 6 | class FileUploadController | ||
| 7 | { | ||
| 8 | static public function checkFileDownload(array $file): bool | ||
| 9 | { | ||
| 10 | $extensions_white_list = ['pdf', 'rtf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp']; // = extensions_white_list côté javascript | ||
| 11 | $mime_type_white_list = ['application/pdf', 'application/rtf', 'text/rtf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.presentation']; | ||
| 12 | |||
| 13 | // 1/ extension | ||
| 14 | $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); | ||
| 15 | if(!in_array($extension, $extensions_white_list, true)){ | ||
| 16 | return false; | ||
| 17 | } | ||
| 18 | |||
| 19 | // 2/ fichier obtenu par HTTP POST (théoriquement inutile si le routeur est solide, mais ça ne mange pas de pain) | ||
| 20 | if(!is_uploaded_file($file['tmp_name'])){ | ||
| 21 | return false; | ||
| 22 | } | ||
| 23 | |||
| 24 | // 3/ objet $finfo valide (dépend du paramètre FILEINFO_MIME_TYPE) | ||
| 25 | $finfo = new finfo(FILEINFO_MIME_TYPE); | ||
| 26 | if($finfo === false){ | ||
| 27 | return false; | ||
| 28 | } | ||
| 29 | |||
| 30 | // 4/ contrôle du "vrai" type mime (finfo_file lit les 1ers octets des fichiers pour y trouver des "signatures", très fiable sauf avec les conteneurs: doc, zip...) | ||
| 31 | $real_type = finfo_file($finfo, $file['tmp_name']); | ||
| 32 | return in_array($real_type, $mime_type_white_list, true); | ||
| 33 | } | ||
| 34 | |||
| 35 | static public function fileUploadTinyMce(): void | ||
| 36 | { | ||
| 37 | if(isset($_FILES['file'])){ | ||
| 38 | $dest = 'user_data/media/'; | ||
| 39 | if(!is_dir($dest)){ // Vérifier si le répertoire existe, sinon le créer | ||
| 40 | mkdir($dest, 0755, true); | ||
| 41 | } | ||
| 42 | |||
| 43 | $name = Security::secureFileName(pathinfo($_FILES['file']['name'], PATHINFO_FILENAME)); // retirer caractères spéciaux et changer espaces en underscores | ||
| 44 | $extension = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); | ||
| 45 | $file_path = $dest . $name . '_' . uniqid() . '.' . $extension; // nom unique | ||
| 46 | |||
| 47 | if(self::checkFileDownload($_FILES['file'])){ | ||
| 48 | if(move_uploaded_file($_FILES['file']['tmp_name'], $file_path)){ | ||
| 49 | echo json_encode(['location' => $file_path]); | ||
| 50 | } | ||
| 51 | else{ | ||
| 52 | http_response_code(500); | ||
| 53 | echo json_encode(['message' => 'Erreur enregistrement du fichier.']); | ||
| 54 | } | ||
| 55 | } | ||
| 56 | else{ | ||
| 57 | http_response_code(400); | ||
| 58 | echo json_encode(['message' => 'Erreur 400: fichier non valide.']); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | else{ | ||
| 62 | http_response_code(400); | ||
| 63 | echo json_encode(['message' => 'Erreur 400: Bad Request']); | ||
| 64 | } | ||
| 65 | die; | ||
| 66 | } | ||
| 67 | } \ No newline at end of file | ||
diff --git a/src/controller/HeadFootController.php b/src/controller/HeadFootController.php index de7ae53..83e27a8 100644 --- a/src/controller/HeadFootController.php +++ b/src/controller/HeadFootController.php | |||
| @@ -4,7 +4,6 @@ | |||
| 4 | declare(strict_types=1); | 4 | declare(strict_types=1); |
| 5 | 5 | ||
| 6 | use App\Entity\NodeData; | 6 | use App\Entity\NodeData; |
| 7 | use App\Entity\NodeDataAsset; | ||
| 8 | use App\Entity\Asset; | 7 | use App\Entity\Asset; |
| 9 | use App\Entity\AssetEmployment; | 8 | use App\Entity\AssetEmployment; |
| 10 | use Doctrine\ORM\EntityManager; | 9 | use Doctrine\ORM\EntityManager; |
| @@ -82,7 +81,7 @@ class HeadFootController | |||
| 82 | } | 81 | } |
| 83 | 82 | ||
| 84 | /* -- écriture du fichier sur le disque -- */ | 83 | /* -- écriture du fichier sur le disque -- */ |
| 85 | if(!ImageUploadController::imagickCleanImage(file_get_contents($file['tmp_name']), Asset::USER_PATH . $name, $extension)){ // recréer l’image pour la nettoyer | 84 | if(!ImageUploadController::imagickCleanAndWriteImage(file_get_contents($file['tmp_name']), Asset::USER_PATH . $name, $extension)){ // recréer l’image pour la nettoyer |
| 86 | http_response_code(500); | 85 | http_response_code(500); |
| 87 | echo json_encode(['success' => false, 'message' => 'Erreur image non valide.', 'format' => $extension]); | 86 | echo json_encode(['success' => false, 'message' => 'Erreur image non valide.', 'format' => $extension]); |
| 88 | } | 87 | } |
| @@ -146,6 +145,4 @@ class HeadFootController | |||
| 146 | } | 145 | } |
| 147 | die; | 146 | die; |
| 148 | } | 147 | } |
| 149 | |||
| 150 | //static public function uploadImage(EntityManager $entityManager, array $request_params): void | ||
| 151 | } \ No newline at end of file | 148 | } \ No newline at end of file |
diff --git a/src/controller/ImageUploadController.php b/src/controller/ImageUploadController.php index 77f0a47..7943116 100644 --- a/src/controller/ImageUploadController.php +++ b/src/controller/ImageUploadController.php | |||
| @@ -5,17 +5,40 @@ declare(strict_types=1); | |||
| 5 | 5 | ||
| 6 | class ImageUploadController | 6 | class ImageUploadController |
| 7 | { | 7 | { |
| 8 | static public function imagickCleanImage(string $image_data, string $local_path, string $format = 'jpeg'): bool // "string" parce que file_get_contents... | 8 | const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'tif']; |
| 9 | |||
| 10 | static public function imagickCleanAndWriteImage(string $image_data, string $local_path): bool // "string" parce que file_get_contents... | ||
| 9 | { | 11 | { |
| 12 | $format = strtolower(pathinfo($local_path)['extension']); | ||
| 10 | try{ | 13 | try{ |
| 11 | $imagick = new Imagick(); | 14 | $imagick = new Imagick(); |
| 12 | $imagick->readImageBlob($image_data); | 15 | $imagick->readImageBlob($image_data); |
| 13 | $imagick->stripImage(); // nettoyage métadonnées | 16 | $imagick->stripImage(); // nettoyage métadonnées |
| 14 | $imagick->setImageFormat($format); | 17 | //$imagick->setImageFormat($format); // inutile, writeImage force la conversion |
| 15 | if($format === 'jpeg'){ | 18 | |
| 16 | $imagick->setImageCompression(Imagick::COMPRESSION_JPEG); | 19 | // compression |
| 17 | $imagick->setImageCompressionQuality(85); // optionnel | 20 | switch($format){ |
| 21 | case 'jpeg': // particularité du switch, si 'jpeg' le test de 'jpg' est ignoré et on va jusqu'au break | ||
| 22 | case 'jpg': | ||
| 23 | $imagick->setImageCompression(Imagick::COMPRESSION_JPEG); | ||
| 24 | $imagick->setImageCompressionQuality(85); | ||
| 25 | break; | ||
| 26 | case 'webp': | ||
| 27 | $imagick->setImageCompression(Imagick::COMPRESSION_WEBP); | ||
| 28 | $imagick->setImageCompressionQuality(85); | ||
| 29 | break; | ||
| 30 | case 'png': | ||
| 31 | $imagick->setImageCompression(Imagick::COMPRESSION_ZIP); | ||
| 32 | $imagick->setImageCompressionQuality(7); // 9 est sans perte | ||
| 33 | break; | ||
| 34 | case 'tiff': | ||
| 35 | $imagick->setImageCompression(Imagick::COMPRESSION_LZW); // LZW est sans perte | ||
| 36 | break; | ||
| 18 | } | 37 | } |
| 38 | |||
| 39 | // enregistrement | ||
| 40 | // writeImage utilise l'extension du fichier et ignore le format détecté | ||
| 41 | // imagemagick est à l'origine une appli console, elle considère que l'extension montre l'intention de | ||
| 19 | $imagick->writeImage($local_path); // enregistrement | 42 | $imagick->writeImage($local_path); // enregistrement |
| 20 | $imagick->clear(); | 43 | $imagick->clear(); |
| 21 | $imagick->destroy(); | 44 | $imagick->destroy(); |
| @@ -25,7 +48,7 @@ class ImageUploadController | |||
| 25 | return false; | 48 | return false; |
| 26 | } | 49 | } |
| 27 | } | 50 | } |
| 28 | static public function curlDownloadImage(string $url, $maxRetries = 3, $timeout = 10): string|false | 51 | static public function curlDownloadImage(string $url, int $maxRetries = 3, int $timeout = 10): string|false |
| 29 | { | 52 | { |
| 30 | $attempt = 0; | 53 | $attempt = 0; |
| 31 | $imageData = false; | 54 | $imageData = false; |
| @@ -43,8 +66,6 @@ class ImageUploadController | |||
| 43 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); | 66 | $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); |
| 44 | //$curlError = curl_error($ch); | 67 | //$curlError = curl_error($ch); |
| 45 | 68 | ||
| 46 | curl_close($ch); | ||
| 47 | |||
| 48 | if($imageData !== false && $httpCode >= 200 && $httpCode < 300){ | 69 | if($imageData !== false && $httpCode >= 200 && $httpCode < 300){ |
| 49 | return $imageData; | 70 | return $imageData; |
| 50 | } | 71 | } |
| @@ -59,42 +80,45 @@ class ImageUploadController | |||
| 59 | // téléchargement par le plugin (bouton "insérer une image") | 80 | // téléchargement par le plugin (bouton "insérer une image") |
| 60 | static public function imageUploadTinyMce(): void | 81 | static public function imageUploadTinyMce(): void |
| 61 | { | 82 | { |
| 62 | if(isset($_FILES['file'])){ | 83 | if(!isset($_FILES['file'])){ |
| 63 | $file = $_FILES['file']; | 84 | http_response_code(400); |
| 64 | $dest = 'user_data/images/'; | ||
| 65 | $dest_mini = 'user_data/images-mini/'; | ||
| 66 | |||
| 67 | // Vérifier si les répertoires existent, sinon les créer | ||
| 68 | if(!is_dir($dest)){ | ||
| 69 | mkdir($dest, 0777, true); | ||
| 70 | } | ||
| 71 | if(!is_dir($dest_mini)){ | ||
| 72 | mkdir($dest_mini, 0777, true); | ||
| 73 | } | ||
| 74 | |||
| 75 | $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'tif']; | ||
| 76 | $name = Security::secureFileName(pathinfo($file['name'], PATHINFO_FILENAME)); | ||
| 77 | $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); | ||
| 78 | if(!in_array($extension, $allowed_extensions) || $extension === 'jpg'){ | ||
| 79 | $extension = 'jpeg'; | ||
| 80 | } | ||
| 81 | $file_path = uniqid($dest . $name . '_') . '.' . $extension; | ||
| 82 | |||
| 83 | // créer une miniature de l'image | ||
| 84 | // | ||
| 85 | |||
| 86 | if(self::imagickCleanImage(file_get_contents($file['tmp_name']), $file_path, $extension)){ // recréer l’image pour la nettoyer | ||
| 87 | echo json_encode(['location' => $file_path]); // renvoyer l'URL de l'image téléchargée | ||
| 88 | } | ||
| 89 | else{ | ||
| 90 | http_response_code(500); | ||
| 91 | echo json_encode(['message' => 'Erreur image non valide', 'format' => $extension]); | ||
| 92 | } | ||
| 93 | } | ||
| 94 | else{ | ||
| 95 | http_response_code(400); | ||
| 96 | echo json_encode(['message' => 'Erreur 400: Bad Request']); | 85 | echo json_encode(['message' => 'Erreur 400: Bad Request']); |
| 97 | } | 86 | } |
| 87 | if(!is_uploaded_file($_FILES['file']['tmp_name'])) { | ||
| 88 | http_response_code(400); | ||
| 89 | echo json_encode(['message' => "Le fichier n'a pas été téléchargé correctement."]); | ||
| 90 | die; | ||
| 91 | } | ||
| 92 | |||
| 93 | $dest = 'user_data/images/'; | ||
| 94 | $dest_mini = 'user_data/images-mini/'; | ||
| 95 | |||
| 96 | // Vérifier si les répertoires existent, sinon les créer | ||
| 97 | if(!is_dir($dest)){ | ||
| 98 | mkdir($dest, 0755, true); | ||
| 99 | } | ||
| 100 | if(!is_dir($dest_mini)){ | ||
| 101 | mkdir($dest_mini, 0755, true); | ||
| 102 | } | ||
| 103 | |||
| 104 | $name = Security::secureFileName(pathinfo($_FILES['file']['name'], PATHINFO_FILENAME)); | ||
| 105 | $extension = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); | ||
| 106 | $image_data = file_get_contents($_FILES['file']['tmp_name']); | ||
| 107 | if(!in_array($extension, self::ALLOWED_EXTENSIONS)){ | ||
| 108 | $extension = 'jpeg'; | ||
| 109 | } | ||
| 110 | $local_path = uniqid($dest . $name . '_') . '.' . $extension; | ||
| 111 | |||
| 112 | // créer une miniature de l'image | ||
| 113 | // | ||
| 114 | |||
| 115 | if(self::imagickCleanAndWriteImage($image_data, $local_path)){ // recréer l’image pour la nettoyer | ||
| 116 | echo json_encode(['location' => $local_path]); // renvoyer l'URL de l'image téléchargée | ||
| 117 | } | ||
| 118 | else{ | ||
| 119 | http_response_code(500); | ||
| 120 | echo json_encode(['message' => 'Erreur image non valide']); | ||
| 121 | } | ||
| 98 | die; | 122 | die; |
| 99 | } | 123 | } |
| 100 | 124 | ||
| @@ -103,40 +127,39 @@ class ImageUploadController | |||
| 103 | { | 127 | { |
| 104 | $json = json_decode(file_get_contents('php://input'), true); | 128 | $json = json_decode(file_get_contents('php://input'), true); |
| 105 | 129 | ||
| 106 | if(isset($json['image_url'])){ | 130 | if(!isset($json['image_url'])){ |
| 107 | $image_data = self::curlDownloadImage($json['image_url']); // téléchargement de l’image par le serveur avec cURL au lieu de file_get_contents | 131 | http_response_code(400); |
| 108 | $dest = 'user_data/images/'; | 132 | echo json_encode(['message' => "Erreur 400: Bad Request"]); |
| 109 | 133 | die; | |
| 110 | if(!is_dir($dest)) { // Vérifier si le répertoire existe, sinon le créer | ||
| 111 | mkdir($dest, 0777, true); | ||
| 112 | } | ||
| 113 | |||
| 114 | if($image_data === false){ | ||
| 115 | http_response_code(400); | ||
| 116 | echo json_encode(['message' => "Erreur, le serveur n'a pas réussi à télécharger l'image."]); | ||
| 117 | die; | ||
| 118 | } | ||
| 119 | |||
| 120 | $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'tif']; | ||
| 121 | $url_path = parse_url($json['image_url'], PHP_URL_PATH); | ||
| 122 | $name = Security::secureFileName(pathinfo($url_path, PATHINFO_FILENAME)); | ||
| 123 | $extension = strtolower(pathinfo($url_path, PATHINFO_EXTENSION)); | ||
| 124 | if(!in_array($extension, $allowed_extensions) || $extension === 'jpg'){ | ||
| 125 | $extension = 'jpeg'; | ||
| 126 | } | ||
| 127 | $local_path = uniqid($dest . $name . '_') . '.' . $extension; | ||
| 128 | |||
| 129 | if(self::imagickCleanImage($image_data, $local_path, $extension)){ // recréer l’image pour la nettoyer | ||
| 130 | echo json_encode(['location' => $local_path]); // nouvelle adresse | ||
| 131 | } | ||
| 132 | else{ | ||
| 133 | http_response_code(500); | ||
| 134 | echo json_encode(['message' => 'Erreur image non valide', 'format' => $extension]); | ||
| 135 | } | ||
| 136 | } | ||
| 137 | else{ | ||
| 138 | echo json_encode(['message' => 'Erreur 400: Bad Request']); | ||
| 139 | } | 134 | } |
| 135 | |||
| 136 | $image_data = self::curlDownloadImage($json['image_url']); // téléchargement de l’image par le serveur avec cURL au lieu de file_get_contents | ||
| 137 | if(!$image_data){ | ||
| 138 | http_response_code(400); | ||
| 139 | echo json_encode(['message' => "Erreur, le serveur n'a pas réussi à télécharger l'image."]); | ||
| 140 | die; | ||
| 141 | } | ||
| 142 | |||
| 143 | $dest = 'user_data/images/'; | ||
| 144 | if(!is_dir($dest)) { // Vérifier si le répertoire existe, sinon le créer | ||
| 145 | mkdir($dest, 0755, true); | ||
| 146 | } | ||
| 147 | |||
| 148 | $url_path = parse_url($json['image_url'], PHP_URL_PATH); | ||
| 149 | $name = Security::secureFileName(pathinfo($url_path, PATHINFO_FILENAME)); | ||
| 150 | $extension = strtolower(pathinfo($url_path, PATHINFO_EXTENSION)); | ||
| 151 | if(!in_array($extension, self::ALLOWED_EXTENSIONS) || $extension === 'jpg'){ | ||
| 152 | $extension = 'jpeg'; | ||
| 153 | } | ||
| 154 | $local_path = uniqid($dest . $name . '_') . '.' . $extension; | ||
| 155 | |||
| 156 | if(self::imagickCleanAndWriteImage($image_data, $local_path)){ // recréer l’image pour la nettoyer | ||
| 157 | echo json_encode(['location' => $local_path]); // nouvelle adresse | ||
| 158 | } | ||
| 159 | else{ | ||
| 160 | http_response_code(500); | ||
| 161 | echo json_encode(['message' => 'Erreur image non valide', 'format' => $extension]); | ||
| 162 | } | ||
| 140 | die; | 163 | die; |
| 141 | } | 164 | } |
| 142 | 165 | ||
| @@ -147,7 +170,7 @@ class ImageUploadController | |||
| 147 | $dest = 'user_data/images/'; | 170 | $dest = 'user_data/images/'; |
| 148 | 171 | ||
| 149 | if(!is_dir($dest)){ | 172 | if(!is_dir($dest)){ |
| 150 | mkdir($dest, 0777, true); | 173 | mkdir($dest, 0755, true); |
| 151 | } | 174 | } |
| 152 | 175 | ||
| 153 | // détection de data:image/ et de ;base64, et capture du format dans $type | 176 | // détection de data:image/ et de ;base64, et capture du format dans $type |
| @@ -157,22 +180,21 @@ class ImageUploadController | |||
| 157 | die; | 180 | die; |
| 158 | } | 181 | } |
| 159 | 182 | ||
| 160 | $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'tif']; | 183 | $extension = strtolower($type[1]); // dans (\w+) |
| 161 | $extension = strtolower($type[1]); | 184 | if(!in_array($extension, self::ALLOWED_EXTENSIONS)){ |
| 162 | if(!in_array($extension, $allowed_extensions) || $extension === 'jpg'){ | ||
| 163 | $extension = 'jpeg'; | 185 | $extension = 'jpeg'; |
| 164 | } | 186 | } |
| 165 | 187 | ||
| 188 | $name = 'pasted_image'; | ||
| 166 | $image_data = base64_decode(substr($json['image_base64'], strpos($json['image_base64'], ',') + 1)); // découpe la chaine à la virgule puis convertit en binaire | 189 | $image_data = base64_decode(substr($json['image_base64'], strpos($json['image_base64'], ',') + 1)); // découpe la chaine à la virgule puis convertit en binaire |
| 167 | if($image_data === false){ | 190 | if($image_data === false){ |
| 168 | http_response_code(400); | 191 | http_response_code(400); |
| 169 | echo json_encode(['message' => 'Décodage base64 invalide']); | 192 | echo json_encode(['message' => 'Décodage base64 invalide']); |
| 170 | die; | 193 | die; |
| 171 | } | 194 | } |
| 172 | 195 | $local_path = uniqid($dest . $name . '_') . '.' . $extension; | |
| 173 | $local_path = uniqid($dest . 'pasted_image_') . '.' . $extension; | ||
| 174 | 196 | ||
| 175 | if(self::imagickCleanImage($image_data, $local_path)){ | 197 | if(self::imagickCleanAndWriteImage($image_data, $local_path)){ |
| 176 | echo json_encode(['location' => $local_path]); | 198 | echo json_encode(['location' => $local_path]); |
| 177 | } | 199 | } |
| 178 | else{ | 200 | else{ |
diff --git a/src/router.php b/src/router.php index 7348f2b..ff219da 100644 --- a/src/router.php +++ b/src/router.php | |||
| @@ -231,12 +231,17 @@ elseif($request->getMethod() === 'POST'){ | |||
| 231 | } | 231 | } |
| 232 | } | 232 | } |
| 233 | 233 | ||
| 234 | // upload d'image dans tinymce avec le plugin (bouton "insérer une image" de l'éditeur) | 234 | // upload avec FormData |
| 235 | elseif(strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) | 235 | elseif(strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) |
| 236 | { | 236 | { |
| 237 | // dans tinymce avec le plugin (bouton "insérer une image" de l'éditeur ou glisser-déposer) | ||
| 237 | if($request->query->has('action') && $request->query->get('action') === 'upload_image_tinymce'){ | 238 | if($request->query->has('action') && $request->query->get('action') === 'upload_image_tinymce'){ |
| 238 | ImageUploadController::imageUploadTinyMce(); | 239 | ImageUploadController::imageUploadTinyMce(); |
| 239 | } | 240 | } |
| 241 | // dans tinymce, des quatre méthodes: bouton "link", drag & drop, html, base64 | ||
| 242 | elseif($request->query->has('action') && $request->query->get('action') === 'upload_file_tinymce'){ | ||
| 243 | FileUploadController::fileUploadTinyMce(); | ||
| 244 | } | ||
| 240 | elseif($request->query->has('head_foot_image')){ | 245 | elseif($request->query->has('head_foot_image')){ |
| 241 | HeadFootController::uploadAsset($entityManager, $request->query->get('head_foot_image')); | 246 | HeadFootController::uploadAsset($entityManager, $request->query->get('head_foot_image')); |
| 242 | } | 247 | } |
