summaryrefslogtreecommitdiff
path: root/src/service
diff options
context:
space:
mode:
authorpolo <ordipolo@gmx.fr>2025-03-21 00:47:16 +0100
committerpolo <ordipolo@gmx.fr>2025-03-21 00:47:16 +0100
commit141291c29306391f91e7ceee57ff4d6e9907ab1d (patch)
tree3896896e18f4c8d84c8108e1c80e3d8c7093e712 /src/service
parent51a5391c96043829f7bbccda1262a51a8b8f93d8 (diff)
downloadckeditor5-master.zip
dossier publicHEADmaster
Diffstat (limited to 'src/service')
-rw-r--r--src/service/ckeditor5/CKEditor.php204
-rw-r--r--src/service/ckeditor5/image_upload.php65
-rw-r--r--src/service/ckeditor5/view.php181
3 files changed, 450 insertions, 0 deletions
diff --git a/src/service/ckeditor5/CKEditor.php b/src/service/ckeditor5/CKEditor.php
new file mode 100644
index 0000000..3adf223
--- /dev/null
+++ b/src/service/ckeditor5/CKEditor.php
@@ -0,0 +1,204 @@
1<?php
2// src/integration/ckeditor5/CKEditor.php
3
4declare(strict_types=1);
5
6class CKEditor
7{
8 public const string INTEGRATION_PATH = '../src/service/ckeditor5/';
9 public const string DATA_PATH = '../data/page';
10 //public const string SERVER_PATH = '/';
11 private const string SERVER_PATH = '/ckeditor5/';
12
13 private string $server_root; // pour les "imports" en javascript
14 // pour l'importmap: j'ai modifié la version "installation avec CDN de la doc pour utiliser les fichiers locaux
15 // l'"importmap" permet d'utiliser "import" (ça ressemble pas mal au python) dans le navigateur comme n'importe quel langage de programmation normal
16
17 private string $storage_mode = 'files';
18 private string $page = 'accueil';
19 private string $from = '';
20
21 private int $php_ini_max_size;
22 private string $css_editor_tag = '';
23 private string $css_outside_editor_tag = '';
24 private string $toolbar_language = 'en';
25
26
27 public function __construct()
28 {
29 $this->php_ini_max_size = $this->returnBytes(ini_get('upload_max_filesize')); // = 2M par défaut dans le php.ini
30 $this->css_outside_editor_tag = '<link rel="stylesheet" href="css/article_hors_editeur.css" />';
31 $this->server_root = $_SERVER['SERVER_NAME'] . self::SERVER_PATH;
32 }
33
34 // getters
35 public function getPage(): string
36 {
37 return $this->page;
38 }
39 public function getFrom(): string
40 {
41 return $this->from;
42 }
43 public function getCSSOutsideEditorTag(): string
44 {
45 return $this->css_outside_editor_tag;
46 }
47 public function getCSSEditorTag(): string
48 {
49 return $this->css_editor_tag;
50 }
51 public function getStorageMode(): string
52 {
53 return $this->storage_mode;
54 }
55
56 // setters
57 public function setPage(string $page): void
58 {
59 $this->page = $page;
60 }
61 public function setFrom(string $from): void
62 {
63 $this->from = $from;
64 }
65 public function setStorageMode($mode): void
66 {
67 $this->storage_mode = $mode;
68 }
69
70 public function setLanguage(string $language): void
71 {
72 $this->toolbar_language = $language;
73 $this->makeTranslationSymLink();
74 }
75
76 public function checkAjaxRequest(): void
77 {
78 // dans un fichier à part parce que trop de lignes
79 require self::INTEGRATION_PATH . 'image_upload.php';
80 die;
81 }
82
83 public function displayNewArticleButton(): string
84 {
85 $html = '<article>
86 <a href="index.php?page=' . $this->page . '&action=open_editor"><button>Nouvel article</button></a>
87 </article>';
88 return $html;
89 }
90 public function displayArticle(string $id, string $texte): string
91 {
92 $html = '<article>' . $texte . '</article>
93 <a href="index.php?page=' . $this->page . '&action=open_editor&id=' . $id . '"><button>Modifier</button></a>
94 <a href="index.php?page=' . $this->page . '&action=delete_article&id=' . $id . '"><button>Supprimer</button></a>';
95 return $html;
96 }
97
98 public function openEditor(string $id = '', string $texte = ''): string
99 {
100 // version "minifiée"
101 $this->css_editor_tag = '<link rel="stylesheet" href="../node_modules/ckeditor5/dist/browser/ckeditor5.css" />';
102 // version normale aérée et commentée"
103 $this->css_editor_tag = '<link rel="stylesheet" href="../node_modules/ckeditor5/dist/ckeditor5.css" />';
104
105 require self::INTEGRATION_PATH . 'view.php'; // html + JS
106 return $editeurHTML;
107 }
108
109 //public function deleteSideEffects(string $id){}
110
111 public function checkSubmitPOST(): string
112 {
113 return $this->getAndCleanEditorOutput($_POST["contenu"]); // manipule $_POST['contenu'];
114
115 // enregistrement des données
116 //var_dump($html_from_editor); // debug
117 //echo "ce programme n'enregistre rien, faîtes-le dans votre application et supprimer cette ligne dans index.php"; die;
118
119 /*header('Location: ' . $this->from);
120 die;*/
121 }
122
123 /* lien symbolique des traductions
124 l'éditeur recherche un dans module/ckeditor5/dist/browser/translations
125 un fichier se trouvant dans module/ckeditor5/dist/translations
126 c'est le meilleur moyen que j'ai trouvé de gérer ça (il y a surement mieux) */
127 private function makeTranslationSymLink(): void
128 {
129 $target = '../translations';
130 $link = '../node_modules/ckeditor5/dist/browser/translations';
131
132 if(!file_exists($link))
133 {
134 if(PHP_OS_FAMILY === 'Linux')
135 {
136 symlink($target, $link);
137 }
138 elseif(PHP_OS_FAMILY === 'Windows') // note: PHP_OS = WINNT
139 {
140 // on peut créer une jointure sans droit d'admin
141 $target = '..\node_modules\ckeditor5\dist\translations';
142 exec('mklink /J ' . str_replace('/', '\\', $link) . ' ' . $target);
143 }
144 else
145 {
146 echo "erreur dans " . self::INTEGRATION_PATH . "CKEditor.php: système d'exploitation n'a pas été reconnu";
147 }
148 // autres valeurs possibles pour PHP_OS_FAMILY: 'BSD', 'Darwin', 'Solaris', 'Unknown'
149 }
150 }
151
152 // conversion des 2M du php.ini en 2000000
153 // note: les kibi, mébi et gibi sont inutiles ici
154 private function returnBytes(string $size_str): int // chaine du style '2M'
155 {
156 switch(substr($size_str, -1))
157 {
158 case 'Ki': case 'ki': return (int)$size_str * 1024;
159 case 'Mi': case 'mi': return (int)$size_str * 1048576;
160 case 'Gi': case 'gi': return (int)$size_str * 1073741824;
161 case 'K': case 'k': return (int)$size_str * 1000;
162 case 'M': case 'm': return (int)$size_str * 1000000;
163 case 'G': case 'g': return (int)$size_str * 1000000000;
164 default: return (int)$size_str;
165 }
166 }
167
168 private function getAndCleanEditorOutput(string $html): string
169 {
170 // bugs possibles sans trim() lorsqu'on insère le HTML dans l'éditeur
171 $html = trim($html);
172
173 //$this->checkContentInFile($html, 'avant');
174
175 // sécurisation du HTML (faille XSS)
176 require '../vendor/htmlawed/htmlawed/htmLawed.php';
177 $configHtmLawed = array(
178 'safe'=>1, // protection contre les élements et attributs dangereux
179
180 // balises autorisées
181 'elements'=>'h2, h3, h4, p, span, i, strong, u, s, mark, blockquote, li, ol, ul, a, figure, hr, img, figcaption, table, tbody, tr, td',
182 // note: change <s></s> en <span style="text-decoration: line-through;"></span>
183
184 // attributs interdits
185 'deny_attribute'=>'id', // 'class' et 'style' sont conservés pour le ckeditor
186 );
187 $specHtmLawed = ''; // optionnel: faire qu'un certain élément puisse n'avoir que certains attributs
188 $html = htmLawed($html, $configHtmLawed, $specHtmLawed);
189
190 //$this->checkContentInFile($html, 'après');
191
192 return $html;
193 }
194
195
196 private function checkContentInFile($html, $file_name): void
197 {
198 $nom_fichier = $file_name . ".html";
199 $fichier = fopen(self::DATA_PATH . '/' . $nom_fichier, 'w'); // w peut créer un fichier, si il existe déjà, il est effacé par le nouveau contenu
200 fputs($fichier, $html);
201 fclose($fichier);
202 chmod(self::DATA_PATH . '/' . $nom_fichier, 0666);
203 }
204}
diff --git a/src/service/ckeditor5/image_upload.php b/src/service/ckeditor5/image_upload.php
new file mode 100644
index 0000000..5cef369
--- /dev/null
+++ b/src/service/ckeditor5/image_upload.php
@@ -0,0 +1,65 @@
1<?php
2// src/integration/ckeditor5/image_upload.php
3
4declare(strict_types=1);
5
6// le "simple upload adapter" envoie un POST appelé: $_FILES['upload']
7// en retour il attend impérativement des données au format JSON du genre: {"url": "data/page/images/monfichier.jpg"}
8// cette adresse doit permettre à l'éditeur de télécharger l'image afficher de manière normale: <img scr="data/page/images/monfichier.jpg">
9
10$erreur = '';
11if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['upload'])
12 && strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) // le "simple upload adapter" envoie des "form-data"
13{
14 if($_FILES['upload']['error'] == 0) // 0 signifie ok
15 {
16 if($_FILES['upload']['size'] <= $this->php_ini_max_size)
17 {
18 $infos = pathinfo ($_FILES['upload']['name']);
19 $extension = $infos['extension'];
20 $extautorisées = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'];
21 // on prend la même liste que celle côté javascript, le SVG est bloqué pour raison de sécurité (javascript à l'intérieur)
22 if(in_array($extension, $extautorisées))
23 {
24 move_uploaded_file ($_FILES['upload']['tmp_name'], self::DATA_PATH . '/images/' . $_FILES['upload']['name']);
25 chmod(self::DATA_PATH . '/images/' . $_FILES['upload']['name'], 0666);
26 }
27 else
28 {
29 $erreur = 'mauvais format, veuillez utiliser une image comportant un de ces formats: jpg ou jpeg, png, gif, bmp, webp, tiff<br />le format svg n\'est pas supporté';
30 }
31 }
32 else
33 {
34 $erreur = 'fichier trop lourd';
35 }
36 }
37 else
38 {
39 $erreur = $_FILES['upload']['error'];
40 }
41
42 if(empty($erreur))
43 {
44 $chemin = '{"url": "' . self::DATA_PATH . '/images/' . $_FILES['upload']['name'] . '"}';
45 echo $chemin;
46 }
47 else
48 {
49 echo $erreur;
50 }
51}
52else
53{
54 echo "erreur: téléchargement non identifié";
55}
56
57/* les erreurs retournées avec $_FILES['upload']['error']:
580 pas d'erreur
591 taille du fichier supérieure à la valeur de upload_max_filesize dans le fichier php.ini (par défaut = 2 MO)
602 taille du fichier supérieure à la valeur de MAX_FILE_SIZE dans le formulaire HTML
613 fichier partiellement téléchargé
624 pas de fichier du tout
636 pas de dossier temporaire pour mettre le fichier
647 echec de l'écriture sur le DD
658 envoi arrêté par une extension de PHP mais on ne nous dit pas pourquoi => diagnostic avec la fonction phpinfo() */ \ No newline at end of file
diff --git a/src/service/ckeditor5/view.php b/src/service/ckeditor5/view.php
new file mode 100644
index 0000000..57115d1
--- /dev/null
+++ b/src/service/ckeditor5/view.php
@@ -0,0 +1,181 @@
1<?php
2// src/integration/ckeditor5/view.php
3
4declare(strict_types=1);
5
6$submit_action = 'index.php?from=' . $this->page . '&action=submit_editor&id=' . $id;
7
8$cancel_button = 'index.php?page=' . $this->page;
9//$cancel_button .= !empty($id) ? '&id=' . $id : ''; // à ajouter si on a besoin de revenir sur un article spécifique
10
11$delete_button = $id != '' ? '<a href="index.php?page=' . $this->page . '&action=delete_article&id=' . $id . '"><input type="button" value="Supprimer"></a>' : '';
12
13$upload_ajax = 'index.php?action=upload_image_editor';
14$upload_ajax .= !empty($id) ? '&id=' . $id : '';
15
16ob_start();
17?>
18<article>
19 <form action="<?= $submit_action ?>" method="POST" enctype="multipart/form-data" >
20 <textarea id="editor" name="contenu" ></textarea>
21 <input type="submit" value="Valider">
22 <a href="<?= $cancel_button ?>"><input type="button" value="Annuler"></a>
23<?= $delete_button ?>
24 <script type="importmap">
25 {
26 "imports": {
27 "ckeditor5": "http://<?= $this->server_root ?>node_modules/ckeditor5/dist/browser/ckeditor5.js",
28 "ckeditor5/": "http://<?= $this->server_root ?>node_modules/ckeditor5/dist/browser/"
29 }
30 }
31 </script>
32 <script type="module">
33 import { // nécessite type="module"
34 ClassicEditor, Essentials, Heading, Paragraph, Alignment, List,
35 BlockQuote, HorizontalLine, Bold, Italic, Underline, Strikethrough,
36 Font, FontFamily, Highlight, FontBackgroundColor, SimpleUploadAdapter,
37 Image, ImageInsert, ImageToolbar, ImageStyle, ImageCaption, LinkImage,
38 Link, Table, TableColumnResize, TableToolbar, TableProperties, TableCellProperties, TextPartLanguage
39 } from "ckeditor5";
40
41 import coreTranslations from 'ckeditor5/translations/<?= $this->toolbar_language ?>.js';
42 // n'utilise pas le bon chemin à cause d'un bug? solution = créer un lien symbolique à l'endroit attendu:
43 // ln -s /srv/http/ckeditor5/node_modules/ckeditor5/dist/translations /srv/http/ckeditor5/node_modules/ckeditor5/dist/browser/
44
45 //import 'ckeditor5/ckeditor5.css'; // marche pas chez moi
46
47 let editor;
48 let html_existant = '<?= $texte ?>'; // $texte doit avoir été sécurisé: simple quotes échappées au minimum
49 let upload_url = '<?= $upload_ajax ?>';
50
51 // ATTENTION: si l'éditeur ne fonctionne pas, empêcher qu'on puisse cliquer sur Valider!
52 // Il y a aussi des paramètres dans le fichier de config: ckeditor/webpack.config.js
53 ClassicEditor
54 .create( document.querySelector( '#editor' ),{
55
56 licenseKey: 'GPL',
57
58 plugins: [ Essentials, Heading, Paragraph, Alignment, List,
59 BlockQuote, HorizontalLine, Underline, Strikethrough,
60 Bold, Italic, Font, FontFamily, Highlight, FontBackgroundColor, SimpleUploadAdapter,
61 Image, ImageInsert, ImageToolbar, ImageStyle, ImageCaption, LinkImage,
62 Link, Table, TableColumnResize, TableToolbar, TableProperties, TableCellProperties, TextPartLanguage],
63
64 toolbar: {
65 items: [
66 'undo', 'redo', 'selectAll', '|', 'heading', '|', 'alignment', 'bulletedList', 'numberedList',
67 //'todoList',// marche pas, ne crée pas de HTML
68 'blockQuote', 'horizontalLine', '|', 'textPartLanguage', '-', 'bold', 'italic', 'underline', 'strikethrough', '|',
69 'fontFamily', // polices microsoft uniquement
70 'fontColor', 'fontSize', 'highlight', 'fontBackgroundColor', // un peu comme highlight
71 '|', 'imageInsert', 'link',
72 //'htmlEmbed', // marche pas, ne crée pas de HTML
73 //'mediaEmbed',
74 'insertTable',
75 ],
76 // multiligne automatique (le '-' dans la liste permet de choisir l'endroit où couper)
77 // pour les plugins indiqués "marche pas", envoient-ils un requête AJAX quelque part?
78
79 shouldNotGroupWhenFull: true
80 },
81
82 language: '<?= $this->toolbar_language ?>',
83 translations: [coreTranslations],
84
85 // barre d'outils dans une image
86 image: {
87 toolbar: [
88 //'imageStyle:full', //inutile?
89 'imageStyle:block',
90 'imageStyle:inline', // complexe, on peut aussi placer l'image à la souris
91 'imageStyle:side',
92 /*'imageStyle:align-left',
93 'imageStyle:align-right',*/
94 //'imageResize', // optionnel? on a les poignées dans les coins de l'image
95 'linkImage',
96 'toggleImageCaption',
97 'imageTextAlternative'
98 ]
99 },
100
101 // barre d'outils dans un tableau
102 table: {
103 contentToolbar: [
104 'tableColumn', // manipulation sur les colonnes et lignes
105 'tableRow',
106 'mergeTableCells',
107 'tableProperties', // style sur la table
108 'tableCellProperties' // style sur une cellule
109 ]
110 },
111
112 // langues dispo pour TextPartLanguage
113 language: {
114 textPartLanguage: [
115 { title: 'Arabic', languageCode: 'ar' },
116 { title: 'English', languageCode: 'en' },
117 { title: 'French', languageCode: 'fr' },
118 { title: 'German', languageCode: 'de' },
119 { title: 'Hebrew', languageCode: 'he' },
120 { title: 'Spanish', languageCode: 'es' }
121 ]
122 },
123
124 // plugin code HTML
125 // voir doc
126
127 // media embarqué (audio, vidéo, carte)
128 //mediaEmbed: {
129 //previewsInData: true,
130 // vaut "false" par defaut, on crée la balise non standard <oembed url="" > // https://oembed.com/
131 // qui nécessite un traitement (en JS ou côté serveur) en utilisant le lien à l'intérieur
132 // avec "true", on crée la balise <iframe> qui sert à insérer une page HTML dans une autre,
133 // notre "embarquement de média" devrait donc réussir quelque soit le site
134 // c'est plus simple, il ne reste plus qu'à ajuster le contenu avec du CSS (important)
135 // on doit supprimer le positionnement absolu qui fait que l'iframe se place par dessus le reste et adapter la taille de l'élément
136
137 // en outre, le plugin mediaEmbed (dans l'éditeur), tout comme la balise "iframe" (hors éditeur),
138 // permettent d'afficher un aperçu (preview): une image avec un bouton lecture dessus
139 // cette image est envoyée imédiatement par le serveur, et le contenu après un clic dessus
140 //
141 //},
142
143 // images
144 // le plugin "simple upload adapter" communique avec le serveur au moyen de requêtes AJAX
145 // il attend du serveur une réponse au format .json contenant l'adresse où est stockée l'image:
146 // {"url": "https://example.com/images/foo.jpg"}
147 simpleUpload: {
148 uploadUrl: upload_url,
149
150 // authentifier l'envoyeur + protection au CSRF
151 headers: {
152 'X-CSRF-TOKEN': 'CSRF-Token',
153 Authorization: 'Bearer <JSON Web Token>'
154 }
155 },
156 // formats acceptés par défaut: jpeg, png, gif, bmp, webp, tiff
157 // le svg n'est pas dans la liste, pour raison de sécurité apparemment, il parait qu'on peut mettre du javascript à l'intérieur
158
159 // plugin autosave
160 // voir doc
161 } )
162 .then( newEditor => {
163 editor = newEditor;
164 // fin de ClassicEditor
165
166 // obtenir la liste des éléments disponibles pour la barre d'outils
167 //alert(Array.from( editor.ui.componentFactory.names() ));
168 // obtenir la liste des plugins disponibles:
169 //alert(ClassicEditor.builtinPlugins.map( plugin => plugin.pluginName ));
170
171 // insertion du HTML existant
172 editor.setData(html_existant);
173 } )
174 .catch( error => {
175 console.error( error );
176 } );
177 </script>
178 </form>
179</article>
180<?php
181$editeurHTML = ob_get_clean();