init
[garradin.git] / include / class.plugin.php
1 <?php
2
3 namespace Garradin;
4
5 class Plugin
6 {
7 protected $id = null;
8 protected $plugin = null;
9
10 protected $mimes = [
11 'css' => 'text/css',
12 'gif' => 'image/gif',
13 'htm' => 'text/html',
14 'html' => 'text/html',
15 'ico' => 'image/x-ico',
16 'jpe' => 'image/jpeg',
17 'jpg' => 'image/jpeg',
18 'jpeg' => 'image/jpeg',
19 'js' => 'application/x-javascript',
20 'pdf' => 'application/pdf',
21 'png' => 'image/png',
22 'swf' => 'application/shockwave-flash',
23 'xml' => 'text/xml',
24 'svg' => 'image/svg+xml',
25 ];
26
27 /**
28 * Construire un objet Plugin pour un plugin
29 * @param string $id Identifiant du plugin
30 * @throws UserException Si le plugin n'est pas installé (n'existe pas en DB)
31 */
32 public function __construct($id)
33 {
34 $db = DB::getInstance();
35 $this->plugin = $db->simpleQuerySingle('SELECT * FROM plugins WHERE id = ?;', true, $id);
36
37 if (!$this->plugin)
38 {
39 throw new UserException('Ce plugin n\'existe pas ou n\'est pas installé correctement.');
40 }
41
42 $this->plugin['config'] = json_decode($this->plugin['config'], true);
43
44 if (!is_array($this->plugin['config']))
45 {
46 $this->plugin['config'] = [];
47 }
48
49 $this->id = $id;
50 }
51
52 /**
53 * Renvoie le chemin absolu vers l'archive du plugin
54 * @return string Chemin PHAR vers l'archive
55 */
56 public function path()
57 {
58 return 'phar://' . PLUGINS_ROOT . '/' . $this->id . '.tar.gz';
59 }
60
61 /**
62 * Renvoie une entrée de la configuration ou la configuration complète
63 * @param string $key Clé à rechercher, ou NULL si on désire toutes les entrées de la
64 * @return mixed L'entrée demandée (mixed), ou l'intégralité de la config (array),
65 * ou NULL si l'entrée demandée n'existe pas.
66 */
67 public function getConfig($key = null)
68 {
69 if (is_null($key))
70 {
71 return $this->plugin['config'];
72 }
73
74 if (array_key_exists($key, $this->plugin['config']))
75 {
76 return $this->plugin['config'][$key];
77 }
78
79 return null;
80 }
81
82 /**
83 * Enregistre une entrée dans la configuration du plugin
84 * @param string $key Clé à modifier
85 * @param mixed $value Valeur à enregistrer, choisir NULL pour effacer cette clé de la configuration
86 * @return boolean TRUE si tout se passe bien
87 */
88 public function setConfig($key, $value = null)
89 {
90 if (is_null($value))
91 {
92 unset($this->plugin['config'][$key]);
93 }
94 else
95 {
96 $this->plugin['config'][$key] = $value;
97 }
98
99 $db = DB::getInstance();
100 $db->simpleUpdate('plugins',
101 ['config' => json_encode($this->plugin['config'])],
102 'id = \'' . $this->id . '\'');
103
104 return true;
105 }
106
107 /**
108 * Renvoie une information ou toutes les informations sur le plugin
109 * @param string $key Clé de l'info à retourner, ou NULL pour recevoir toutes les infos
110 * @return mixed Info demandée ou tableau des infos.
111 */
112 public function getInfos($key = null)
113 {
114 if (is_null($key))
115 {
116 return $this->plugin;
117 }
118
119 if (array_key_exists($key, $this->plugin))
120 {
121 return $this->plugin[$key];
122 }
123
124 return null;
125 }
126
127 /**
128 * Renvoie l'identifiant du plugin
129 * @return string Identifiant du plugin
130 */
131 public function id()
132 {
133 return $this->id;
134 }
135
136 /**
137 * Inclure un fichier depuis le plugin (dynamique ou statique)
138 * @param string $file Chemin du fichier à aller chercher : si c'est un .php il sera inclus,
139 * sinon il sera juste affiché
140 * @return void
141 * @throws UserException Si le fichier n'existe pas ou fait partie des fichiers qui ne peuvent
142 * être appelés que par des méthodes de Plugin.
143 * @throws RuntimeException Si le chemin indiqué tente de sortir du contexte du PHAR
144 */
145 public function call($file)
146 {
147 $file = preg_replace('!^[./]*!', '', $file);
148
149 if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file))
150 {
151 throw new \RuntimeException('Chemin de fichier incorrect.');
152 }
153
154 $forbidden = ['install.php', 'garradin_plugin.ini', 'upgrade.php', 'uninstall.php', 'signals.php'];
155
156 if (in_array($file, $forbidden))
157 {
158 throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
159 }
160
161 if (!file_exists($this->path() . '/www/' . $file))
162 {
163 throw new UserException('Le fichier ' . $file . ' n\'existe pas dans le plugin ' . $this->id);
164 }
165
166 $plugin = $this;
167 global $tpl, $config, $user, $membres;
168
169 if (substr($file, -4) === '.php')
170 {
171 include $this->path() . '/www/' . $file;
172 }
173 else
174 {
175 // Récupération du type MIME à partir de l'extension
176 $ext = substr($file, strrpos($file, '.')+1);
177
178 if (isset($this->mimes[$ext]))
179 {
180 $mime = $this->mimes[$ext];
181 }
182 else
183 {
184 $mime = 'text/plain';
185 }
186
187 header('Content-Type: ' .$this->mimes[$ext]);
188 header('Content-Length: ' . filesize($this->path() . '/www/' . $file));
189
190 readfile($this->path() . '/www/' . $file);
191 }
192 }
193
194 /**
195 * Désinstaller le plugin
196 * @return boolean TRUE si la suppression a fonctionné
197 */
198 public function uninstall()
199 {
200 if (file_exists($this->path() . '/uninstall.php'))
201 {
202 include $this->path() . '/uninstall.php';
203 }
204
205 unlink(PLUGINS_ROOT . '/' . $this->id . '.tar.gz');
206
207 $db = DB::getInstance();
208 return $db->simpleExec('DELETE FROM plugins WHERE id = ?;', $this->id);
209 }
210
211 /**
212 * Renvoie TRUE si le plugin a besoin d'être mis à jour
213 * (si la version notée dans la DB est différente de la version notée dans garradin_plugin.ini)
214 * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
215 */
216 public function needUpgrade()
217 {
218 $infos = parse_ini_file($this->path() . '/garradin_plugin.ini', false);
219
220 if (version_compare($this->plugin['version'], $infos['version'], '!='))
221 return true;
222
223 return false;
224 }
225
226 /**
227 * Mettre à jour le plugin
228 * Appelle le fichier upgrade.php dans l'archive si celui-ci existe.
229 * @return boolean TRUE si tout a fonctionné
230 */
231 public function upgrade()
232 {
233 if (file_exists($this->path() . '/upgrade.php'))
234 {
235 include $this->path() . '/upgrade.php';
236 }
237
238 $db = DB::getInstance();
239 return $db->simpleUpdate('plugins',
240 'id = \''.$db->escapeString($this->id).'\'',
241 ['version' => $infos['version']]);
242 }
243
244 /**
245 * Liste des plugins installés (en DB)
246 * @return array Liste des plugins triés par nom
247 */
248 static public function listInstalled()
249 {
250 $db = DB::getInstance();
251 $plugins = $db->simpleStatementFetchAssocKey('SELECT id, * FROM plugins ORDER BY nom;');
252 $system = explode(',', PLUGINS_SYSTEM);
253
254 foreach ($plugins as &$row)
255 {
256 $row['system'] = in_array($row['id'], $system);
257 }
258
259 return $plugins;
260 }
261
262 /**
263 * Liste les plugins qui doivent être affichés dans le menu
264 * @return array Tableau associatif id => nom (ou un tableau vide si aucun plugin ne doit être affiché)
265 */
266 static public function listMenu()
267 {
268 $db = DB::getInstance();
269 return $db->simpleStatementFetchAssoc('SELECT id, nom FROM plugins WHERE menu = 1 ORDER BY nom;');
270 }
271
272 /**
273 * Liste les plugins téléchargés mais non installés
274 * @return array Liste des plugins téléchargés
275 */
276 static public function listDownloaded()
277 {
278 $installed = self::listInstalled();
279
280 $list = [];
281 $dir = dir(PLUGINS_ROOT);
282
283 while ($file = $dir->read())
284 {
285 if (substr($file, 0, 1) == '.')
286 continue;
287
288 if (!preg_match('!^([a-z0-9_.-]+)\.tar\.gz$!', $file, $match))
289 continue;
290
291 if (array_key_exists($match[1], $installed))
292 continue;
293
294 $list[$match[1]] = parse_ini_file('phar://' . PLUGINS_ROOT . '/' . $match[1] . '.tar.gz/garradin_plugin.ini', false);
295 }
296
297 $dir->close();
298
299 return $list;
300 }
301
302 /**
303 * Liste des plugins officiels depuis le repository signé
304 * @return array Liste des plugins
305 */
306 static public function listOfficial()
307 {
308 // La liste est stockée en cache une heure pour ne pas tuer le serveur distant
309 if (Static_Cache::expired('plugins_list', 3600 * 24))
310 {
311 $url = parse_url(PLUGINS_URL);
312
313 $context_options = [
314 'ssl' => [
315 'verify_peer' => TRUE,
316 // On vérifie en utilisant le certificat maître de CACert
317 'cafile' => ROOT . '/include/data/cacert.pem',
318 'verify_depth' => 5,
319 'CN_match' => $url['host'],
320 'SNI_enabled' => true,
321 'SNI_server_name' => $url['host'],
322 'disable_compression' => true,
323 ]
324 ];
325
326 $context = stream_context_create($context_options);
327
328 try {
329 $result = file_get_contents(PLUGINS_URL, NULL, $context);
330 }
331 catch (\Exception $e)
332 {
333 throw new UserException('Le téléchargement de la liste des plugins a échoué : ' . $e->getMessage());
334 }
335
336 Static_Cache::store('plugins_list', $result);
337 }
338 else
339 {
340 $result = Static_Cache::get('plugins_list');
341 }
342
343 $list = json_decode($result, true);
344 return $list;
345 }
346
347 /**
348 * Vérifier le hash du plugin $id pour voir s'il correspond au hash du fichier téléchargés
349 * @param string $id Identifiant du plugin
350 * @return boolean TRUE si le hash correspond (intégrité OK), sinon FALSE
351 */
352 static public function checkHash($id)
353 {
354 $list = self::fetchOfficialList();
355
356 if (!array_key_exists($id, $list))
357 return null;
358
359 $hash = sha1_file(PLUGINS_ROOT . '/' . $id . '.tar.gz');
360
361 return ($hash === $list[$id]['hash']);
362 }
363
364 /**
365 * Est-ce que le plugin est officiel ?
366 * @param string $id Identifiant du plugin
367 * @return boolean TRUE si le plugin est officiel, FALSE sinon
368 */
369 static public function isOfficial($id)
370 {
371 $list = self::fetchOfficialList();
372 return array_key_exists($id, $list);
373 }
374
375 /**
376 * Télécharge un plugin depuis le repository officiel, et l'installe
377 * @param string $id Identifiant du plugin
378 * @return boolean TRUE si ça marche
379 * @throws LogicException Si le plugin n'est pas dans la liste des plugins officiels
380 * @throws UserException Si le plugin est déjà installé ou que le téléchargement a échoué
381 * @throws RuntimeException Si l'archive téléchargée est corrompue (intégrité du hash ne correspond pas)
382 */
383 static public function download($id)
384 {
385 $list = self::fetchOfficialList();
386
387 if (!array_key_exists($id, $list))
388 {
389 throw new \LogicException($id . ' n\'est pas un plugin officiel (absent de la liste)');
390 }
391
392 if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
393 {
394 throw new UserException('Le plugin '.$id.' existe déjà.');
395 }
396
397 $url = parse_url(PLUGINS_URL);
398
399 $context_options = [
400 'ssl' => [
401 'verify_peer' => TRUE,
402 'cafile' => ROOT . '/include/data/cacert.pem',
403 'verify_depth' => 5,
404 'CN_match' => $url['host'],
405 'SNI_enabled' => true,
406 'SNI_server_name' => $url['host'],
407 'disable_compression' => true,
408 ]
409 ];
410
411 $context = stream_context_create($context_options);
412
413 try {
414 copy($list[$id]['phar'], PLUGINS_ROOT . '/' . $id . '.tar.gz', $context);
415 }
416 catch (\Exception $e)
417 {
418 throw new UserException('Le téléchargement du plugin '.$id.' a échoué : ' . $e->getMessage());
419 }
420
421 if (!self::checkHash($id))
422 {
423 unlink(PLUGINS_ROOT . '/' . $id . '.tar.gz');
424 throw new \RuntimeException('L\'archive du plugin '.$id.' est corrompue (le hash SHA1 ne correspond pas).');
425 }
426
427 self::install($id, true);
428
429 return true;
430 }
431
432 /**
433 * Installer un plugin
434 * @param string $id Identifiant du plugin
435 * @param boolean $official TRUE si le plugin est officiel
436 * @return boolean TRUE si tout a fonctionné
437 */
438 static public function install($id, $official = false)
439 {
440 if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz'))
441 {
442 throw new \RuntimeException('Le plugin ' . $id . ' ne semble pas exister et ne peut donc être installé.');
443 }
444
445 if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/garradin_plugin.ini'))
446 {
447 throw new UserException('L\'archive '.$id.'.tar.gz n\'est pas une extension Garradin : fichier garradin_plugin.ini manquant.');
448 }
449
450 $infos = parse_ini_file('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/garradin_plugin.ini', false);
451
452 $required = ['nom', 'description', 'auteur', 'url', 'version', 'menu', 'config'];
453
454 foreach ($required as $key)
455 {
456 if (!array_key_exists($key, $infos))
457 {
458 throw new \RuntimeException('Le fichier garradin_plugin.ini ne contient pas d\'entrée "'.$key.'".');
459 }
460 }
461
462 if (!empty($infos['menu']) && !file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/www/admin/index.php'))
463 {
464 throw new \RuntimeException('Le plugin '.$id.' ne comporte pas de fichier www/admin/index.php alors qu\'il demande à figurer au menu.');
465 }
466
467 $config = '';
468
469 if ((bool)$infos['config'])
470 {
471 if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/config.json'))
472 {
473 throw new \RuntimeException('L\'archive '.$id.'.tar.gz ne comporte pas de fichier config.json
474 alors que le plugin nécessite le stockage d\'une configuration.');
475 }
476
477 if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/www/admin/config.php'))
478 {
479 throw new \RuntimeException('L\'archive '.$id.'.tar.gz ne comporte pas de fichier www/admin/config.php
480 alors que le plugin nécessite le stockage d\'une configuration.');
481 }
482
483 $config = json_decode(file_get_contents('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/config.json'), true);
484
485 if (is_null($config))
486 {
487 throw new \RuntimeException('config.json invalide. Code erreur JSON: ' . json_last_error());
488 }
489
490 $config = json_encode($config);
491 }
492
493 $db = DB::getInstance();
494 $db->simpleInsert('plugins', [
495 'id' => $id,
496 'officiel' => (int)(bool)$official,
497 'nom' => $infos['nom'],
498 'description'=> $infos['description'],
499 'auteur' => $infos['auteur'],
500 'url' => $infos['url'],
501 'version' => $infos['version'],
502 'menu' => (int)(bool)$infos['menu'],
503 'config' => $config,
504 ]);
505
506 if (file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/install.php'))
507 {
508 include 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/install.php';
509 }
510
511 return true;
512 }
513
514 /**
515 * Renvoie la version installée d'un plugin ou FALSE s'il n'est pas installé
516 * @param string $id Identifiant du plugin
517 * @return mixed Numéro de version du plugin ou FALSE
518 */
519 static public function getInstalledVersion($id)
520 {
521 return DB::getInstance()->simpleQuerySingle('SELECT version FROM plugins WHERE id = ?;');
522 }
523 }