Overview
Comment:Refactor plugins to have the same UI for plugins and modules, also modernize code of plugins management
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | dev
Files: files | file ages | folders
SHA3-256: a8251477ac0350b4d690e9cdcb8123a497589be0ce367a38983a77d8fc476f66
User & Date: bohwaz on 2023-02-14 22:29:09
Other Links: branch diff | manifest | tags
References
2023-02-14
22:52 Fixed ticket [6c2655d5f4]: Fusionner la gestion plugins et modules plus 5 other changes artifact: a2ee117896 user: bohwaz
Context
2023-02-14
22:57
Update session cache for plugins/modules list check-in: c9f67aaddc user: bohwaz tags: dev
22:29
Refactor plugins to have the same UI for plugins and modules, also modernize code of plugins management check-in: a8251477ac user: bohwaz tags: dev
15:02
Use INI file instead of JSON for Module metadata (easier to use), allow to add home button and menu item without any code check-in: 366dbe8987 user: bohwaz tags: dev
Changes

Modified src/include/init.php from [5a1976ea53] to [f4edb73edf].

110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
	// Plugins
	if (substr($classname, 0, 16) == 'Garradin\\Plugin\\')
	{
		$classname = substr($classname, 16);
		$plugin_name = substr($classname, 0, strpos($classname, '\\'));
		$filename = str_replace('\\', '/', substr($classname, strpos($classname, '\\')+1));

		$path = Plugin::getPath(strtolower($plugin_name)) . '/lib/' . $filename . '.php';
	}
	else
	{
		// PSR-0 autoload
		$filename = str_replace('\\', '/', $classname);
		$path = ROOT . '/include/lib/' . $filename . '.php';
	}







|







110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
	// Plugins
	if (substr($classname, 0, 16) == 'Garradin\\Plugin\\')
	{
		$classname = substr($classname, 16);
		$plugin_name = substr($classname, 0, strpos($classname, '\\'));
		$filename = str_replace('\\', '/', substr($classname, strpos($classname, '\\')+1));

		$path = Plugins::getPath(strtolower($plugin_name)) . '/lib/' . $filename . '.php';
	}
	else
	{
		// PSR-0 autoload
		$filename = str_replace('\\', '/', $classname);
		$path = ROOT . '/include/lib/' . $filename . '.php';
	}

Modified src/include/lib/Garradin/Accounting/Reports.php from [428de3f988] to [da4d116997].

176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
		else {
			$where = self::getWhereClause($criterias);
			$sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where);
		}

		$balances = DB::getInstance()->getAssoc($sql);

		//var_dump('<pre>', $sql, $balances[Account::REVENUE]); exit;

		return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0);
	}

	static public function getBalancesSQL(array $parts = [])
	{
		return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, %s, is_debt
			FROM (







<
<







176
177
178
179
180
181
182


183
184
185
186
187
188
189
		else {
			$where = self::getWhereClause($criterias);
			$sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where);
		}

		$balances = DB::getInstance()->getAssoc($sql);



		return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0);
	}

	static public function getBalancesSQL(array $parts = [])
	{
		return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, %s, is_debt
			FROM (

Modified src/include/lib/Garradin/Email/Emails.php from [967d4a8042] to [173d6f484a].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Email;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\Entities\Email\Email;
use Garradin\Entities\Users\User;
use Garradin\Users\DynamicFields;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;
use Garradin\Web\Skeleton;







|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Email;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Entities\Email\Email;
use Garradin\Entities\Users\User;
use Garradin\Users\DynamicFields;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;
use Garradin\Web\Skeleton;
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
		if (!count($list)) {
			return;
		}

		$recipients = $list;
		unset($list);

		if (Plugin::fireSignal('email.queue.before', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			// queue handling was done by a plugin
			return;
		}

		$template = ($content instanceof UserTemplate) ? $content : null;
		$skel = null;
		$content_html = null;







|







112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
		if (!count($list)) {
			return;
		}

		$recipients = $list;
		unset($list);

		if (Plugins::fireSignal('email.queue.before', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			// queue handling was done by a plugin
			return;
		}

		$template = ($content instanceof UserTemplate) ? $content : null;
		$skel = null;
		$content_html = null;
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
					'recipient' => $to,
					'data'      => $variables,
					'context'   => $context,
					'from'      => $sender,
				]);
			}

			if (Plugin::fireSignal('email.queue.insert', compact('context', 'to', 'sender', 'subject', 'content', 'render', 'hash', 'content_html') + ['pgp_key' => $data['pgp_key'] ?? null])) {
				// queue insert was done by a plugin
				continue;
			}

			$st->bindValue(':sender', $sender);
			$st->bindValue(':subject', $subject);
			$st->bindValue(':context', $context);
			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_pgp_key', $variables['pgp_key'] ?? null);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $content);
			$st->bindValue(':content_html', $content_html);
			$st->execute();

			$st->reset();
			$st->clear();
		}

		$db->commit();

		if (Plugin::fireSignal('email.queue.after', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			return;
		}

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}







|




















|







170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
					'recipient' => $to,
					'data'      => $variables,
					'context'   => $context,
					'from'      => $sender,
				]);
			}

			if (Plugins::fireSignal('email.queue.insert', compact('context', 'to', 'sender', 'subject', 'content', 'render', 'hash', 'content_html') + ['pgp_key' => $data['pgp_key'] ?? null])) {
				// queue insert was done by a plugin
				continue;
			}

			$st->bindValue(':sender', $sender);
			$st->bindValue(':subject', $subject);
			$st->bindValue(':context', $context);
			$st->bindValue(':recipient', $to);
			$st->bindValue(':recipient_pgp_key', $variables['pgp_key'] ?? null);
			$st->bindValue(':recipient_hash', $hash);
			$st->bindValue(':content', $content);
			$st->bindValue(':content_html', $content_html);
			$st->execute();

			$st->reset();
			$st->clear();
		}

		$db->commit();

		if (Plugins::fireSignal('email.queue.after', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) {
			return;
		}

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597

	static public function sendMessage(int $context, Mail_Message $message): void
	{
		if (DISABLE_EMAIL) {
			return;
		}

		$email_sent_via_plugin = Plugin::fireSignal('email.send.before', compact('context', 'message'));

		if ($email_sent_via_plugin) {
			return;
		}

		if (SMTP_HOST) {
			$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
			$secure = constant($const);

			$smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure);
			$smtp->send($message);
		}
		else {
			$message->send();
		}

		Plugin::fireSignal('email.send.after', compact('context', 'message'));
	}

	/**
	 * Handle a bounce message
	 * @param  string $raw_message Raw MIME message from SMTP
	 */
	static public function handleBounce(string $raw_message): ?array
	{
		$message = new Mail_Message;
		$message->parse($raw_message);

		$return = $message->identifyBounce();

		if (Plugin::fireSignal('email.bounce', compact('message', 'return', 'raw_message'))) {
			return null;
		}

		if (!$return) {
			return null;
		}








|
















|













|







552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597

	static public function sendMessage(int $context, Mail_Message $message): void
	{
		if (DISABLE_EMAIL) {
			return;
		}

		$email_sent_via_plugin = Plugins::fireSignal('email.send.before', compact('context', 'message'));

		if ($email_sent_via_plugin) {
			return;
		}

		if (SMTP_HOST) {
			$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
			$secure = constant($const);

			$smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure);
			$smtp->send($message);
		}
		else {
			$message->send();
		}

		Plugins::fireSignal('email.send.after', compact('context', 'message'));
	}

	/**
	 * Handle a bounce message
	 * @param  string $raw_message Raw MIME message from SMTP
	 */
	static public function handleBounce(string $raw_message): ?array
	{
		$message = new Mail_Message;
		$message->parse($raw_message);

		$return = $message->identifyBounce();

		if (Plugins::fireSignal('email.bounce', compact('message', 'return', 'raw_message'))) {
			return null;
		}

		if (!$return) {
			return null;
		}

625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
		$return = compact('recipient', 'type', 'message');
		$email = self::getOrCreateEmail($return['recipient']);

		if (!$email) {
			return null;
		}

		Plugin::fireSignal('email.bounce', compact('email', 'return'));
		$email->hasFailed($return);
		$email->save();

		return $return;
	}









|







625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
		$return = compact('recipient', 'type', 'message');
		$email = self::getOrCreateEmail($return['recipient']);

		if (!$email) {
			return null;
		}

		Plugins::fireSignal('email.bounce', compact('email', 'return'));
		$email->hasFailed($return);
		$email->save();

		return $return;
	}


Modified src/include/lib/Garradin/Entities/Files/File.php from [7450a23eb6] to [89f8d9569c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;
use KD2\Graphics\Blob;
use KD2\DB\EntityManager as EM;
use KD2\Security;

use Garradin\Config;
use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugin;
use Garradin\Template;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;












|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;
use KD2\Graphics\Blob;
use KD2\DB\EntityManager as EM;
use KD2\Security;

use Garradin\Config;
use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugins;
use Garradin\Template;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
		Files::callStorage('checkLock');

		Web_Cache::delete($this->uri());

		// Delete actual file content
		Files::callStorage('delete', $this);

		Plugin::fireSignal('files.delete', ['file' => $this]);

		// clean up thumbnails
		foreach (self::ALLOWED_THUMB_SIZES as $key => $operations)
		{
			Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key));
		}








|







198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
		Files::callStorage('checkLock');

		Web_Cache::delete($this->uri());

		// Delete actual file content
		Files::callStorage('delete', $this);

		Plugins::fireSignal('files.delete', ['file' => $this]);

		// clean up thumbnails
		foreach (self::ALLOWED_THUMB_SIZES as $key => $operations)
		{
			Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key));
		}

263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
				throw new UserException(sprintf('Impossible de renommer "%s" vers "%s"', $this->path, $new_path));
			}
		}

		Files::ensureDirectoryExists(Utils::dirname($new_path));
		$return = Files::callStorage('move', $this, $new_path);

		Plugin::fireSignal('files.move', ['file' => $this, 'new_path' => $new_path]);

		return $return;
	}

	/**
	 * Copy the current file to a new location
	 * @param  string $target Target path







|







263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
				throw new UserException(sprintf('Impossible de renommer "%s" vers "%s"', $this->path, $new_path));
			}
		}

		Files::ensureDirectoryExists(Utils::dirname($new_path));
		$return = Files::callStorage('move', $this, $new_path);

		Plugins::fireSignal('files.move', ['file' => $this, 'new_path' => $new_path]);

		return $return;
	}

	/**
	 * Copy the current file to a new location
	 * @param  string $target Target path
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
			fclose($pointer);
		}

		if (!$return) {
			throw new UserException('Le fichier n\'a pas pu être enregistré.');
		}

		Plugin::fireSignal('files.store', ['file' => $this]);

		if ($index_search && $content) {
			$this->indexForSearch($content);
		}
		else {
			$this->removeFromSearch();
		}







|







403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
			fclose($pointer);
		}

		if (!$return) {
			throw new UserException('Le fichier n\'a pas pu être enregistré.');
		}

		Plugins::fireSignal('files.store', ['file' => $this]);

		if ($index_search && $content) {
			$this->indexForSearch($content);
		}
		else {
			$this->removeFromSearch();
		}

Modified src/include/lib/Garradin/Entities/Module.php from [3f935b6b38] to [d85fe33668].

13
14
15
16
17
18
19
20

21
22
23
24
25
26
27
28
use const Garradin\{ROOT, WWW_URL};

class Module extends Entity
{
	const ROOT = File::CONTEXT_SKELETON . '/modules';
	const DIST_ROOT = ROOT . '/skel-dist/modules';
	const META_FILE = 'module.ini';


	const CONFIG_TEMPLATE = 'config.html';

	// Snippets, don't forget to create alias constant in UserTemplate\Modules class
	const SNIPPET_TRANSACTION = 'snippets/transaction_details.html';
	const SNIPPET_USER = 'snippets/user_details.html';
	const SNIPPET_HOME_BUTTON = 'snippets/home_button.html';

	const SNIPPETS = [







|
>
|







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use const Garradin\{ROOT, WWW_URL};

class Module extends Entity
{
	const ROOT = File::CONTEXT_SKELETON . '/modules';
	const DIST_ROOT = ROOT . '/skel-dist/modules';
	const META_FILE = 'module.ini';
	const ICON_FILE = 'icon.svg';
	const README_FILE = 'README.md';
	const CONFIG_FILE = 'config.html';

	// Snippets, don't forget to create alias constant in UserTemplate\Modules class
	const SNIPPET_TRANSACTION = 'snippets/transaction_details.html';
	const SNIPPET_USER = 'snippets/user_details.html';
	const SNIPPET_HOME_BUTTON = 'snippets/home_button.html';

	const SNIPPETS = [
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
		$this->set('restrict_level', isset($ini->restrict_section, $ini->restrict_level, Session::ACCESS_WORDS[$ini->restrict_level]) ? Session::ACCESS_WORDS[$ini->restrict_level] : null);

		return true;
	}

	public function updateTemplates(): void
	{
		$check = self::SNIPPETS + [self::CONFIG_TEMPLATE => 'Config'];
		$templates = [];
		$db = DB::getInstance();

		$db->begin();
		$db->delete('modules_templates', 'id_module = ' . (int)$this->id());

		foreach ($check as $file => $label) {
			if (Files::exists($this->path($file)) || file_exists($this->distPath($file))) {
				$templates[] = $file;
				$db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file]);
			}
		}

		$db->commit();
	}

	public function icon_url(): ?string
	{
		if (!$this->hasFile('icon.svg')) {
			return null;
		}

		return $this->url('icon.svg');
	}

	public function path(string $file = null): string
	{
		return self::ROOT . '/' . $this->name . ($file ? '/' . $file : '');
	}








|


















|



|







95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
		$this->set('restrict_level', isset($ini->restrict_section, $ini->restrict_level, Session::ACCESS_WORDS[$ini->restrict_level]) ? Session::ACCESS_WORDS[$ini->restrict_level] : null);

		return true;
	}

	public function updateTemplates(): void
	{
		$check = self::SNIPPETS + [self::CONFIG_FILE => 'Config'];
		$templates = [];
		$db = DB::getInstance();

		$db->begin();
		$db->delete('modules_templates', 'id_module = ' . (int)$this->id());

		foreach ($check as $file => $label) {
			if (Files::exists($this->path($file)) || file_exists($this->distPath($file))) {
				$templates[] = $file;
				$db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file]);
			}
		}

		$db->commit();
	}

	public function icon_url(): ?string
	{
		if (!$this->hasFile(self::ICON_FILE)) {
			return null;
		}

		return $this->url(self::ICON_FILE);
	}

	public function path(string $file = null): string
	{
		return self::ROOT . '/' . $this->name . ($file ? '/' . $file : '');
	}

152
153
154
155
156
157
158





159
160
161
162





163
164
165
166
167
168
169
170
171
172
173
174
		return false;
	}

	public function hasDist(): bool
	{
		return file_exists($this->distPath());
	}






	public function hasConfig(): bool
	{
		return DB::getInstance()->test('modules_templates', 'id_module = ? AND name = ?', $this->id(), self::CONFIG_TEMPLATE);





	}

	public function canDelete(): bool
	{
		return $this->dir() ? true : false;
	}

	public function delete(): bool
	{
		$dir = $this->dir();

		if ($dir) {







>
>
>
>
>



|
>
>
>
>
>




|







153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
		return false;
	}

	public function hasDist(): bool
	{
		return file_exists($this->distPath());
	}

	public function hasLocal(): bool
	{
		return Files::exists($this->path());
	}

	public function hasConfig(): bool
	{
		return DB::getInstance()->test('modules_templates', 'id_module = ? AND name = ?', $this->id(), self::CONFIG_FILE);
	}

	public function hasData(): bool
	{
		return DB::getInstance()->test('sqlite_master', 'type = \'table\' AND name = ?', sprintf('modules_data_%s', $this->name));
	}

	public function canDelete(): bool
	{
		return !empty($this->config) || $this->hasLocal() || $this->hasData();
	}

	public function delete(): bool
	{
		$dir = $this->dir();

		if ($dir) {
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
		if (!preg_match('!^(?:snippets/)?[\w\d_-]+(?:\.[\w\d_-]+)*$!i', $file)) {
			throw new \InvalidArgumentException('Invalid skeleton name');
		}
	}

	public function template(string $file)
	{
		if ($file == self::CONFIG_TEMPLATE) {
			Session::getInstance()->requireAccess(Session::SECTION_CONFIG, Session::ACCESS_ADMIN);
		}

		$this->validateFileName($file);

		$ut = new UserTemplate('modules/' . $this->name . '/' . $file);
		$ut->assign('module', array_merge($this->asArray(false), ['url' => $this->url()]));







|







205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
		if (!preg_match('!^(?:snippets/)?[\w\d_-]+(?:\.[\w\d_-]+)*$!i', $file)) {
			throw new \InvalidArgumentException('Invalid skeleton name');
		}
	}

	public function template(string $file)
	{
		if ($file == self::CONFIG_FILE) {
			Session::getInstance()->requireAccess(Session::SECTION_CONFIG, Session::ACCESS_ADMIN);
		}

		$this->validateFileName($file);

		$ut = new UserTemplate('modules/' . $this->name . '/' . $file);
		$ut->assign('module', array_merge($this->asArray(false), ['url' => $this->url()]));

Added src/include/lib/Garradin/Entities/Plugin.php version [a599f0ad51].



































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
<?php

namespace Garradin\Entities;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Template;
use Garradin\UserException;
use Garradin\Files\Files;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Users\Session;
use Garradin\Web\Render\Parsedown;

use Garradin\Entities\Files\File;

use const Garradin\{PLUGINS_ROOT, WWW_URL, ROOT, ADMIN_URL};

class Plugin extends Entity
{
	const META_FILE = 'plugin.ini';
	const CONFIG_FILE = 'admin/config.php';
	const INDEX_FILE = 'admin/index.php';
	const ICON_FILE = 'admin/icon.svg';
	const INSTALL_FILE = 'install.php';
	const UPGRADE_FILE = 'upgrade.php';
	const UNINSTALL_FILE = 'uninstall.php';
	const README_FILE = 'admin/README.md';

	const PROTECTED_FILES = [
		self::META_FILE,
		self::INSTALL_FILE,
		self::UPGRADE_FILE,
		self::UNINSTALL_FILE,
	];

	const MIME_TYPES = [
		'css'  => 'text/css',
		'gif'  => 'image/gif',
		'htm'  => 'text/html',
		'html' => 'text/html',
		'ico'  => 'image/x-ico',
		'jpe'  => 'image/jpeg',
		'jpg'  => 'image/jpeg',
		'jpeg' => 'image/jpeg',
		'js'   => 'application/javascript',
		'pdf'  => 'application/pdf',
		'png'  => 'image/png',
		'xml'  => 'text/xml',
		'svg'  => 'image/svg+xml',
		'webp' => 'image/webp',
		'md'   => 'text/x-markdown',
	];

	const TABLE = 'plugins';

	protected ?int $id;

	/**
	 * Directory name
	 */
	protected string $name;

	protected string $label;
	protected string $version;

	protected ?string $description;
	protected ?string $author;
	protected ?string $url;

	protected bool $home_button;
	protected bool $menu;
	protected ?string $restrict_section;
	protected ?int $restrict_level;

	protected ?\stdClass $config;
	protected bool $enabled;

	public function hasCode(): bool
	{
		return file_exists($this->path());
	}

	public function selfCheck(): void
	{
		$this->assert(preg_match('/^' . Plugins::NAME_REGEXP . '$/', $this->name), 'Nom unique d\'extension invalide: ' . $this->name);
		$this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide');
		$this->assert(trim($this->version) !== '', 'La version ne peut rester vide');
		$this->assert(!$this->menu || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "menu" est activée.');
		$this->assert(!$this->home_button || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "home_button" est activée.');
		$this->assert(!$this->home_button || $this->hasFile(self::ICON_FILE), 'Le fichier admin/icon.svg n\'existe pas alors que la directive "home_button" est activée.');
	}

	/**
	 * Fills information from plugin.ini file
	 */
	public function updateFromINI(): bool
	{
		$ini = parse_ini_file($this->path(self::META_FILE), false, \INI_SCANNER_TYPED);

		if (empty($ini)) {
			return false;
		}

		$ini = (object) $ini;

		if (!isset($ini->name)) {
			return false;
		}

		$this->assert(empty($ini->min_version) || version_compare(\Garradin\garradin_version(), $ini->min_version, '>='), sprintf('L\'extension "%s" nécessite Paheko version %s ou supérieure.', $this->name, $ini->min_version));

		$this->set('label', $ini->name);
		$this->set('version', $ini->version);
		$this->set('description', $ini->description ?? null);
		$this->set('author', $ini->author ?? null);
		$this->set('url', $ini->url ?? null);
		$this->set('home_button', !empty($ini->home_button));
		$this->set('menu', !empty($ini->menu));
		$this->set('restrict_section', $ini->restrict_section ?? null);
		$this->set('restrict_level', isset($ini->restrict_section, $ini->restrict_level, Session::ACCESS_WORDS[$ini->restrict_level]) ? Session::ACCESS_WORDS[$ini->restrict_level] : null);

		return true;
	}

	public function icon_url(): ?string
	{
		if (!$this->hasFile(self::ICON_FILE)) {
			return null;
		}

		return $this->url(self::ICON_FILE);
	}

	public function path(string $file = null): string
	{
		return Plugins::getPath($this->name) . ($file ? '/' . $file : '');
	}

	public function hasFile(string $file): bool
	{
		return file_exists($this->path($file));
	}

	public function hasConfig(): bool
	{
		return $this->hasFile(self::CONFIG_FILE);
	}

	public function url(string $file = '', array $params = null)
	{
		if (null !== $params) {
			$params = '?' . http_build_query($params);
		}

		return sprintf('%sp/%s/%s%s', WWW_URL, $this->name, $file, $params);
	}

	public function getConfig(string $key = null)
	{
		if (is_null($key)) {
			return $this->config;
		}

		if (property_exists($this->config, $key)) {
			return $this->config->$key;
		}

		return null;
	}

	public function setConfigProperty(string $key, $value = null)
	{
		if (is_null($value)) {
			unset($this->config->$key);
		}
		else {
			$this->config->$key = $value;
		}

		$this->_modified['config'] = true;
	}

	public function setConfig(\stdClass $config)
	{
		$this->config = $config;
		$this->_modified['config'] = true;
	}

	/**
	 * Associer un signal à un callback du plugin
	 * @param  string $signal   Nom du signal (par exemple boucle.agenda pour la boucle de type AGENDA)
	 * @param  mixed  $callback Callback, sous forme d'un nom de fonction ou de méthode statique
	 * @return boolean TRUE
	 */
	public function registerSignal(string $signal, callable $callback): void
	{
		$callable_name = '';

		if (!is_callable($callback, true, $callable_name) || !is_string($callable_name))
		{
			throw new \LogicException('Le callback donné n\'est pas valide.');
		}

		// pour empêcher d'appeler des méthodes de Garradin après un import de base de données "hackée"
		if (strpos($callable_name, 'Garradin\\Plugin\\') !== 0)
		{
			throw new \LogicException('Le callback donné n\'utilise pas le namespace Garradin\\Plugin : ' . $callable_name);
		}

		$db = DB::getInstance();

		$callable_name = str_replace('Garradin\\Plugin\\', '', $callable_name);

		$db->preparedQuery('INSERT OR REPLACE INTO plugins_signals VALUES (?, ?, ?);', [$signal, $this->name, $callable_name]);
	}

	public function unregisterSignal(string $signal): void
	{
		DB::getInstance()->preparedQuery('DELETE FROM plugins_signals WHERE plugin = ? AND signal = ?;', [$this->name, $signal]);
	}

	public function delete(): bool
	{
		if ($this->hasFile(self::UNINSTALL_FILE)) {
			$this->call(self::UNINSTALL_FILE, true);
		}

		$db = DB::getInstance();
		$db->delete('plugins_signals', 'plugin = ?', $this->name);
		return parent::delete();
	}

	/**
	 * Renvoie TRUE si le plugin a besoin d'être mis à jour
	 * (si la version notée dans la DB est différente de la version notée dans paheko_plugin.ini)
	 * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
	 */
	public function needUpgrade(): bool
	{
		$infos = (object) parse_ini_file($this->path(self::META_FILE), false);

		if (version_compare($this->version, $infos->version, '!=')) {
			return true;
		}

		return false;
	}

	/**
	 * Mettre à jour le plugin
	 * Appelle le fichier upgrade.php dans l'archive si celui-ci existe.
	 */
	public function upgrade(): void
	{
		$this->updateFromINI();

		if ($this->hasFile(self::UPGRADE_FILE)) {
			$this->call(self::UPGRADE_FILE, true);
		}

		$this->save();
	}

	public function oldVersion(): ?string
	{
		return $this->getModifiedProperty('version');
	}

	public function call(string $file, bool $allow_protected = false): void
	{
		$file = ltrim($file, './');

		if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file)) {
			throw new \UnexpectedValueException('Chemin de fichier incorrect.');
		}

		if (!$allow_protected && in_array($file, self::PROTECTED_FILES)) {
			throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
		}

		$path = $this->path($file);

		if (!file_exists($path)) {
			throw new UserException(sprintf('Le fichier "%s" n\'existe pas dans le plugin "%s"', $file, $this->name));
		}

		if (is_dir($path)) {
			throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->name));
		}

		$is_private = (0 === strpos($file, 'admin/'));

		// Créer l'environnement d'exécution du plugin
		if (substr($file, -4) === '.php') {
			if (substr($file, 0, 6) == 'admin/' || substr($file, 0, 7) == 'public/') {
				define('Garradin\PLUGIN_ROOT', $this->path());
				define('Garradin\PLUGIN_URL', WWW_URL . 'p/' . $this->name . '/');
				define('Garradin\PLUGIN_ADMIN_URL', WWW_URL .'admin/p/' . $this->name . '/');
				define('Garradin\PLUGIN_QSP', '?');

				$tpl = Template::getInstance();

				if ($is_private) {
					require ROOT . '/www/admin/_inc.php';
					$tpl->assign('current', 'plugin_' . $this->name);
				}

				$tpl->assign('plugin', $this);
				$tpl->assign('plugin_url', \Garradin\PLUGIN_URL);
				$tpl->assign('plugin_admin_url', \Garradin\PLUGIN_ADMIN_URL);
				$tpl->assign('plugin_root', \Garradin\PLUGIN_ROOT);
			}

			$plugin = $this;

			include $path;
		}
		elseif (substr($file, -3) === '.md' && $is_private) {
			$p = new ParseDown(null, null);
			header('Content-Type: text/html');

			printf('<!DOCYPE html><head>
				<style type="text/css">body { font-family: Verdana, sans-serif; padding: .5em; margin: 0; background: #fff; color: #000; }</style>
				<link rel="stylesheet" type="text/css" href="%scss.php" /></head><body>', ADMIN_URL);
			echo $p->text(file_get_contents($path));
		}
		else {
			// Récupération du type MIME à partir de l'extension
			$pos = strrpos($path, '.');
			$ext = substr($path, $pos+1);

			$mime = self::MIME_TYPES[$ext] ?? 'text/plain';

			header('Content-Type: ' .$mime);
			header('Content-Length: ' . filesize($path));
			header('Cache-Control: public, max-age=3600');
			header('Last-Modified: ' . date(DATE_RFC7231, filemtime($path)));

			readfile($path);
		}
	}

	public function route(string $uri): void
	{
		$uri = ltrim($uri, '/');

		if (0 !== strpos($uri, 'admin/')) {
			$uri = 'public/' . $uri;
		}

		if (!$uri || substr($uri, -1) == '/') {
			$uri .= 'index.php';
		}

		try {
			$this->call($uri);
		}
		catch (\UnexpectedValueException $e) {
			http_response_code(404);
			throw new UserException($e->getMessage());
		}
	}

	public function isAvailable(): bool
	{
		return $this->hasFile(self::META_FILE);
	}
}

Modified src/include/lib/Garradin/Entity.php from [1c303aef6f] to [f2feecdbe4].

113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
			$this->selfCheck();
		}

		$new = $this->exists() ? false : true;
		$modified = $this->isModified();

		// Specific entity signal
		if (Plugin::fireSignal($name . '.before', ['entity' => $this, 'new' => $new])) {
			return true;
		}

		// Generic entity signal
		if (Plugin::fireSignal('entity.save.before', ['entity' => $this, 'new' => $new])) {
			return true;
		}

		$return = parent::save(false);

		// Log creation/edit, but don't record stuff that doesn't change anything
		if ($this::NAME && ($new || $modified)) {
			$type = str_replace('Garradin\Entities\\', '', get_class($this));
			Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => $type, 'id' => $this->id()]);
		}

		Plugin::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		Plugin::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		return $return;
	}

	public function delete(): bool
	{
		$type = get_class($this);
		$type = str_replace('Garradin\Entities\\', '', $type);
		$name = 'entity.' . $name . '.delete';

		$id = $this->id();

		if (Plugin::fireSignal($name . '.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		// Generic entity signal
		if (Plugin::fireSignal('entity.delete.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		$return = parent::delete();

		if ($this::NAME) {
			Log::add(Log::DELETE, ['entity' => $name, 'id' => $id]);
		}

		Plugin::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'id' => $id]);
		Plugin::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return, 'id' => $id]);

		return $return;
	}
}







|




|











|

|








|



|




|









|
|




113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
			$this->selfCheck();
		}

		$new = $this->exists() ? false : true;
		$modified = $this->isModified();

		// Specific entity signal
		if (Plugins::fireSignal($name . '.before', ['entity' => $this, 'new' => $new])) {
			return true;
		}

		// Generic entity signal
		if (Plugins::fireSignal('entity.save.before', ['entity' => $this, 'new' => $new])) {
			return true;
		}

		$return = parent::save(false);

		// Log creation/edit, but don't record stuff that doesn't change anything
		if ($this::NAME && ($new || $modified)) {
			$type = str_replace('Garradin\Entities\\', '', get_class($this));
			Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => $type, 'id' => $this->id()]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		Plugins::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		return $return;
	}

	public function delete(): bool
	{
		$type = get_class($this);
		$type = str_replace('Garradin\Entities\\', '', $type);
		$name = 'entity.' . $type . '.delete';

		$id = $this->id();

		if (Plugins::fireSignal($name . '.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		// Generic entity signal
		if (Plugins::fireSignal('entity.delete.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		$return = parent::delete();

		if ($this::NAME) {
			Log::add(Log::DELETE, ['entity' => $name, 'id' => $id]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'id' => $id]);
		Plugins::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return, 'id' => $id]);

		return $return;
	}
}

Modified src/include/lib/Garradin/Files/Files.php from [80bfb98411] to [c6af2950e6].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Files;

use Garradin\Static_Cache;
use Garradin\Config;
use Garradin\DB;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Files;

use Garradin\Static_Cache;
use Garradin\Config;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;

867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
			'image'    => false,
		]);

		$file->modified = new \DateTime;

		Files::callStorage('mkdir', $file);

		Plugin::fireSignal('files.mkdir', compact('file'));

		return $file;
	}

	static public function ensureDirectoryExists(string $path): void
	{
		$db = DB::getInstance();







|







867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
			'image'    => false,
		]);

		$file->modified = new \DateTime;

		Files::callStorage('mkdir', $file);

		Plugins::fireSignal('files.mkdir', compact('file'));

		return $file;
	}

	static public function ensureDirectoryExists(string $path): void
	{
		$db = DB::getInstance();

Modified src/include/lib/Garradin/Plugin.php from [6a4226dab2] to [00ecaf4706].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
<?php

namespace Garradin;

use Garradin\Users\Session;

class Plugin
{
	const PLUGIN_ID_REGEXP = '[a-z]+(?:_[a-z]+)*';

	protected $id = null;
	protected $plugin = null;
	protected $config_changed = false;

	/**
	 * Set to false to disable signal firing
	 * @var boolean
	 */
	static protected $signals = true;

	protected $mimes = [
		'css' => 'text/css',
		'gif' => 'image/gif',
		'htm' => 'text/html',
		'html' => 'text/html',
		'ico' => 'image/x-ico',
		'jpe' => 'image/jpeg',
		'jpg' => 'image/jpeg',
		'jpeg' => 'image/jpeg',
		'js' => 'application/javascript',
		'pdf' => 'application/pdf',
		'png' => 'image/png',
		'xml' => 'text/xml',
		'svg' => 'image/svg+xml',
	];

	static public function toggleSignals(bool $enabled) {
		self::$signals = $enabled;
	}

	static public function getPath($id)
	{
		if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
		{
			return 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz';
		}
		elseif (is_dir(PLUGINS_ROOT . '/' . $id))
		{
			return PLUGINS_ROOT . '/' . $id;
		}

		return false;
	}

	static public function getURL(string $id, string $path = '')
	{
		return ADMIN_URL . 'p/' . $id . '/' . ltrim($path, '/');
	}

	static public function getPublicURL(string $id, string $path = '')
	{
		return WWW_URL . 'p/' . $id . '/' . ltrim($path, '/');
	}

	/**
	 * Construire un objet Plugin pour un plugin
	 * @param string $id Identifiant du plugin
	 * @throws UserException Si le plugin n'est pas installé (n'existe pas en DB)
	 */
	public function __construct(string $id)
	{
		$db = DB::getInstance();
		$this->plugin = $db->first('SELECT * FROM plugins WHERE id = ?;', $id);

		if (!$this->plugin)
		{
			throw new UserException(sprintf('Le plugin "%s" n\'existe pas ou n\'est pas installé correctement.', $id));
		}

		$this->plugin->config = json_decode($this->plugin->config ?? '');

		if (!is_object($this->plugin->config))
		{
			$this->plugin->config = new \stdClass;
		}

		// Juste pour vérifier que le fichier source du plugin existe bien
		self::getPath($id);

		$this->id = $id;
	}

	/**
	 * Enregistrer les changements dans la config
	 */
	public function __destruct()
	{
		if ($this->config_changed)
		{
			$db = DB::getInstance();
			$db->update('plugins', 
				['config' => json_encode($this->plugin->config)],
				'id = \'' . $this->id . '\'');
		}
	}

	/**
	 * Renvoie le chemin absolu vers l'archive du plugin
	 * @return string Chemin PHAR vers l'archive
	 */
	public function path()
	{
		return self::getPath($this->id);
	}

	/**
	 * Renvoie une entrée de la configuration ou la configuration complète
	 * @param  string $key Clé à rechercher, ou NULL si on désire toutes les entrées de la
	 * @return mixed       L'entrée demandée (mixed), ou l'intégralité de la config (array),
	 * ou NULL si l'entrée demandée n'existe pas.
	 */
	public function getConfig($key = null)
	{
		if (is_null($key))
		{
			return $this->plugin->config;
		}

		if (property_exists($this->plugin->config, $key))
		{
			return $this->plugin->config->$key;
		}

		return null;
	}

	/**
	 * Enregistre une entrée dans la configuration du plugin
	 * @param string $key   Clé à modifier
	 * @param mixed  $value Valeur à enregistrer, choisir NULL pour effacer cette clé de la configuration
	 * @return boolean 		TRUE si tout se passe bien
	 */
	public function setConfig($key, $value = null)
	{
		if (is_null($value))
		{
			unset($this->plugin->config->$key);
		}
		else
		{
			$this->plugin->config->$key = $value;
		}

		$this->config_changed = true;

		return true;
	}

	/**
	 * Remplace toute la config du plugin
	 * @param \stdClass $config Configuration complète du plugin
	 */
	public function setConfigAll(\stdClass $config)
	{
		$this->plugin->config = $config;
		$this->config_changed = true;
		return true;
	}

	/**
	 * Renvoie une information ou toutes les informations sur le plugin
	 * @param  string $key Clé de l'info à retourner, ou NULL pour recevoir toutes les infos
	 * @return mixed       Info demandée ou tableau des infos.
	 */
	public function getInfos($key = null)
	{
		if (is_null($key))
		{
			return $this->plugin;
		}

		if (property_exists($this->plugin, $key))
		{
			return $this->plugin->$key;
		}

		return null;
	}

	/**
	 * Renvoie l'identifiant du plugin
	 * @return string Identifiant du plugin
	 */
	public function id()
	{
		return $this->id;
	}

	public function route(bool $public, string $uri): void
	{
		if (!$uri || substr($uri, -1) == '/') {
			$uri .= 'index.php';
		}

		try {
			$this->call($public, $uri);
		}
		catch (\UnexpectedValueException $e) {
			http_response_code(404);
			throw new UserException($e->getMessage());
		}
	}

	/**
	 * Inclure un fichier depuis le plugin (dynamique ou statique)
	 * @param bool   $public TRUE si le fichier est situé dans 'public', sinon dans 'admin'
	 * @param string $file   Chemin du fichier à aller chercher : si c'est un .php il sera inclus,
	 * sinon il sera juste affiché
	 * @return void
	 * @throws UserException Si le fichier n'existe pas ou fait partie des fichiers qui ne peuvent
	 * être appelés que par des méthodes de Plugin.
	 * @throws \RuntimeException Si le chemin indiqué tente de sortir du contexte du PHAR
	 */
	public function call(bool $public, string $file)
	{
		$file = preg_replace('!^[./]*!', '', $file);

		if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file))
		{
			throw new \UnexpectedValueException('Chemin de fichier incorrect.');
		}

		$forbidden = ['install.php', 'paheko_plugin.ini', 'upgrade.php', 'uninstall.php'];

		if (in_array(basename($file), $forbidden))
		{
			throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
		}

		$path = $this->path();

		if (!$path) {
			throw new UserException('Cette extension n\'est pas disponible.');
		}

		$path .= $public ? '/public/' : '/admin/';
		$path .= $file;

		if (!file_exists($path)) {
			throw new UserException(sprintf('Le fichier "%s" n\'existe pas dans le plugin "%s"', substr($path, strlen($this->path())), $this->id));
		}

		if (is_dir($path)) {
			throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->id));
		}

		if (substr($path, -4) === '.php')
		{
			define('Garradin\PLUGIN_ROOT', $this->path());
			define('Garradin\PLUGIN_URL', WWW_URL . 'p/' . $this->id() . '/');
			define('Garradin\PLUGIN_ADMIN_URL', WWW_URL .'admin/p/' . $this->id() . '/');
			define('Garradin\PLUGIN_QSP', '?');

			// Créer l'environnement d'exécution du plugin
			$plugin = $this;

			if (!$public) {
				require ROOT . '/www/admin/_inc.php';
			}

			$tpl = Template::getInstance();
			$tpl->assign('plugin', $this->getInfos());
			$tpl->assign('plugin_url', PLUGIN_URL);
			$tpl->assign('plugin_admin_url', PLUGIN_ADMIN_URL);
			$tpl->assign('plugin_root', PLUGIN_ROOT);

			include $path;
		}
		else
		{
			// Récupération du type MIME à partir de l'extension
			$pos = strrpos($path, '.');
			$ext = substr($path, $pos+1);

			if (isset($this->mimes[$ext]))
			{
				$mime = $this->mimes[$ext];
			}
			else
			{
				$mime = 'text/plain';
			}

			header('Content-Type: ' .$mime);
			header('Content-Length: ' . filesize($path));
			header('Cache-Control: public, max-age=3600');
			header('Last-Modified: ' . date(DATE_RFC7231, filemtime($path)));

			readfile($path);
		}
	}

	/**
	 * Désinstaller le plugin
	 * @return boolean TRUE si la suppression a fonctionné
	 */
	public function uninstall()
	{
		if (file_exists($this->path() . '/uninstall.php'))
		{
			$plugin = $this;
			include $this->path() . '/uninstall.php';
		}

		$db = DB::getInstance();
		$db->delete('plugins_signals', 'plugin = ?', $this->id);
		return $db->delete('plugins', 'id = ?', $this->id);
	}

	/**
	 * Renvoie TRUE si le plugin a besoin d'être mis à jour
	 * (si la version notée dans la DB est différente de la version notée dans paheko_plugin.ini)
	 * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
	 */
	public function needUpgrade()
	{
		$infos = (object) parse_ini_file($this->path() . '/paheko_plugin.ini', false);

		if (version_compare($this->plugin->version, $infos->version, '!=')) {
			return true;
		}

		return false;
	}

	/**
	 * Mettre à jour le plugin
	 * Appelle le fichier upgrade.php dans l'archive si celui-ci existe.
	 * @return boolean TRUE si tout a fonctionné
	 */
	public function upgrade(): void
	{
		$infos = (object) parse_ini_file($this->path() . '/paheko_plugin.ini', false);

		if (!isset($infos->name)) {
			return;
		}

		if (file_exists($this->path() . '/upgrade.php'))
		{
			$plugin = $this;
			include $this->path() . '/upgrade.php';
		}

		$data = [
			'name'		=>	$infos->name,
			'description'=>	$infos->description,
			'author'	=>	$infos->author,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
		];

		if ($config = self::getDefaultConfig($this->id, $this->path())) {
			$data['config'] = json_encode($config);
		}

		DB::getInstance()->update('plugins', $data, 'id = :id', ['id' => $this->id]);
	}

	/**
	 * Associer un signal à un callback du plugin
	 * @param  string $signal   Nom du signal (par exemple boucle.agenda pour la boucle de type AGENDA)
	 * @param  mixed  $callback Callback, sous forme d'un nom de fonction ou de méthode statique
	 * @return boolean TRUE
	 */
	public function registerSignal(string $signal, callable $callback): void
	{
		$callable_name = '';

		if (!is_callable($callback, true, $callable_name) || !is_string($callable_name))
		{
			throw new \LogicException('Le callback donné n\'est pas valide.');
		}

		// pour empêcher d'appeler des méthodes de Garradin après un import de base de données "hackée"
		if (strpos($callable_name, 'Garradin\\Plugin\\') !== 0)
		{
			throw new \LogicException('Le callback donné n\'utilise pas le namespace Garradin\\Plugin : ' . $callable_name);
		}

		$db = DB::getInstance();

		$callable_name = str_replace('Garradin\\Plugin\\', '', $callable_name);

		$db->preparedQuery('INSERT OR REPLACE INTO plugins_signals VALUES (?, ?, ?);', [$signal, $this->id, $callable_name]);
	}

	public function unregisterSignal(string $signal): void
	{
		DB::getInstance()->preparedQuery('DELETE FROM plugins_signals WHERE plugin = ? AND signal = ?;', [$this->id, $signal]);
	}

	/**
	 * Liste des plugins installés (en DB)
	 * @return array Liste des plugins triés par nom
	 */
	static public function listInstalled()
	{
		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, * FROM plugins ORDER BY name;');

		foreach ($plugins as &$row)
		{
			$row->disabled = !self::getPath($row->id);
		}

		return $plugins;
	}

	/**
	 * Checks if a plugin requires an upgrade and upgrade it
	 * This is run after an upgrade, a database restoration, or in the Plugins page
	 */
	static public function upgradeAllIfRequired(): bool
	{
		$i = 0;

		// Mettre à jour les plugins si nécessaire
		foreach (self::listInstalled() as $id => $infos)
		{
			// Ne pas tenir compte des plugins dont le code n'est pas dispo
			if ($infos->disabled) {
				continue;
			}

			$plugin = new Plugin($id);

			if ($plugin->needUpgrade()) {
				$plugin->upgrade();
				$i++;
			}

			unset($plugin);
		}

		return $i > 0;
	}

	/**
	 * Liste les plugins qui doivent être affichés dans le menu
	 * @return array Tableau associatif id => nom (ou un tableau vide si aucun plugin ne doit être affiché)
	 */
	static public function listMenu(Session $session)
	{
		$list = [];

		// Let plugins handle their listing
		self::fireSignal('menu.item', compact('session'), $list);
		ksort($list);

		return $list;
	}

	/**
	 * Liste les plugins téléchargés mais non installés
	 * @return array Liste des plugins téléchargés
	 */
	static public function listDownloaded(bool $remove_installed_from_list = true)
	{
		if ($remove_installed_from_list) {
			$installed = self::listInstalled();
		}
		else {
			$installed = [];
		}

		$list = [];

		foreach (glob(PLUGINS_ROOT . '/*') as $file)
		{
			$file = basename($file);

			if (substr($file, 0, 1) == '.')
				continue;

			if (preg_match('!^(' . self::PLUGIN_ID_REGEXP . ')\.tar\.gz$!', $file, $match))
			{
				// Sélectionner les archives PHAR
				$file = $match[1];
			}
			elseif (is_dir(PLUGINS_ROOT . '/' . $file)
				&& preg_match('!^' . self::PLUGIN_ID_REGEXP . '$!', $file)
				&& is_file(sprintf('%s/%s/paheko_plugin.ini', PLUGINS_ROOT, $file)))
			{
				// Rien à faire, le nom valide du plugin est déjà dans "$file"
			}
			else
			{
				// ignorer tout ce qui n'est pas un répertoire ou une archive PHAR valides
				continue;
			}

			if (array_key_exists($file, $installed))
			{
				// Ignorer les plugins déjà installés
				continue;
			}

			$data = (object) parse_ini_file(self::getPath($file) . '/paheko_plugin.ini', false);;

			if (!isset($data->name)) {
				// Ignore old plugins
				continue;
			}

			$list[$file] = $data;
		}

		ksort($list);

		return $list;
	}

	/**
	 * Installer un plugin
	 * @param  string  $id       Identifiant du plugin
	 * @return boolean           TRUE si tout a fonctionné
	 */
	static public function install($id)
	{
		$path = self::getPath($id);

		if (!file_exists($path . '/paheko_plugin.ini'))
		{
			throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Garradin : fichier paheko_plugin.ini manquant.', $id));
		}

		$infos = (object) parse_ini_file($path . '/paheko_plugin.ini', false);

		$required = ['name', 'description', 'author', 'url', 'version'];

		foreach ($required as $key)
		{
			if (!property_exists($infos, $key))
			{
				throw new \RuntimeException('Le fichier paheko_plugin.ini ne contient pas d\'entrée "'.$key.'".');
			}
		}

		if (!empty($infos->min_version) && !version_compare(garradin_version(), $infos->min_version, '>='))
		{
			throw new UserException('Le plugin '.$id.' nécessite Garradin version '.$infos->min_version.' ou supérieure.');
		}

		if (!empty($infos->max_version) && !version_compare(garradin_version(), $infos->max_version, '>'))
		{
			throw new UserException('Le plugin '.$id.' nécessite Garradin version '.$infos->max_version.' ou inférieure.');
		}

		$config = self::getDefaultConfig($id, $path);

		$data = [
			'id' 		=> 	$id,
			'name'		=>	$infos->name,
			'description'=>	$infos->description,
			'author'	=>	$infos->author,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
			'config'	=>	$config ? json_encode($config) : null,
		];

		$db = DB::getInstance();
		$db->begin();
		$db->insert('plugins', $data);

		if (file_exists($path . '/install.php'))
		{
			$plugin = new Plugin($id);
			require $plugin->path() . '/install.php';
		}

		$db->commit();

		return true;
	}

	static protected function getDefaultConfig(string $id, string $path)
	{
		$config = null;

		if (file_exists($path . '/config.json'))
		{
			if (!file_exists($path . '/admin/config.php'))
			{
				throw new \RuntimeException(sprintf('Le plugin "%s" ne comporte pas de fichier admin/config.php
					alors que le plugin nécessite le stockage d\'une configuration.', $id));
			}

			$config = json_decode(file_get_contents($path . '/config.json'));

			if (is_null($config))
			{
				throw new \RuntimeException('config.json invalide. Erreur JSON: ' . json_last_error_msg());
			}
		}

		return $config;
	}

	/**
	 * Renvoie la version installée d'un plugin ou FALSE s'il n'est pas installé
	 * @param  string $id Identifiant du plugin
	 * @return mixed      Numéro de version du plugin ou FALSE
	 */
	static public function getInstalledVersion($id)
	{
		return DB::getInstance()->first('SELECT version FROM plugins WHERE id = ?;', $id);
	}

	/**
	 * Déclenche le signal donné auprès des plugins enregistrés
	 * @param  string $signal Nom du signal
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stoppé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{
		if (!self::$signals) {
			return null;
		}

		// Process SYSTEM_SIGNALS first
		foreach (SYSTEM_SIGNALS as $system_signal) {
			if (key($system_signal) != $signal) {
				continue;
			}

			if (!is_callable(current($system_signal))) {
				throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), key($system_signal)));
			}

			if (true === call_user_func_array(current($system_signal), [&$params, &$callback_return])) {
				return true;
			}
		}

		$list = DB::getInstance()->get('SELECT * FROM plugins_signals WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];
		}

		foreach ($list as $row)
		{
			$path = self::getPath($row->plugin);

			// Ne pas appeler les plugins dont le code n'existe pas/plus,
			if (!$path)
			{
				continue;
			}

			$params['plugin_root'] = $path;

			$return = call_user_func_array('Garradin\\Plugin\\' . $row->callback, [&$params, &$callback_return]);

			if (true === $return) {
				return true;
			}
		}

		return false;
	}
}








<

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<











<
<
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<

1
2
3
4
5
6
7
8

9











































10
11
12
13
14
15
16
17
18
19
20






21

















































































































































































































































































































22














































































































































































































































































































23
<?php

namespace Garradin;

use Garradin\Users\Session;

class Plugin
{














































	static public function getURL(string $id, string $path = '')
	{
		return ADMIN_URL . 'p/' . $id . '/' . ltrim($path, '/');
	}

	static public function getPublicURL(string $id, string $path = '')
	{
		return WWW_URL . 'p/' . $id . '/' . ltrim($path, '/');
	}








































































































































































































































































































































































































































































































































































































































}

Added src/include/lib/Garradin/Plugins.php version [cbba2e85be].













































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
<?php

namespace Garradin;

use Garradin\Entities\Plugin;
use Garradin\Entities\Module;

use Garradin\Users\Session;
use Garradin\DB;
use Garradin\UserTemplate\CommonFunctions;
use Garradin\UserTemplate\Modules;

use \KD2\DB\EntityManager as EM;

use const Garradin\{SYSTEM_SIGNALS, ADMIN_URL};

class Plugins
{
	const NAME_REGEXP = '[a-z][a-z0-9]*(?:_[a-z0-9]+)*';

	/**
	 * Set to false to disable signal firing
	 * @var boolean
	 */
	static protected $signals = true;

	static public function toggleSignals(bool $enabled)
	{
		self::$signals = $enabled;
	}

	static public function getPath(string $name): string
	{
		if (file_exists(PLUGINS_ROOT . '/' . $name . '.tar.gz')) {
			return 'phar://' . PLUGINS_ROOT . '/' . $name . '.tar.gz';
		}
		else {
			return PLUGINS_ROOT . '/' . $name;
		}
	}

	/**
	 * Déclenche le signal donné auprès des plugins enregistrés
	 * @param  string $signal Nom du signal
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stoppé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{
		if (!self::$signals) {
			return null;
		}

		// Process SYSTEM_SIGNALS first
		foreach (SYSTEM_SIGNALS as $system_signal) {
			if (key($system_signal) != $signal) {
				continue;
			}

			if (!is_callable(current($system_signal))) {
				throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), key($system_signal)));
			}

			if (true === call_user_func_array(current($system_signal), [&$params, &$callback_return])) {
				return true;
			}
		}

		$list = DB::getInstance()->get('SELECT * FROM plugins_signals WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];
		}

		foreach ($list as $row)
		{
			$path = self::getPath($row->plugin);

			// Ne pas appeler les plugins dont le code n'existe pas/plus,
			if (!$path)
			{
				continue;
			}

			$params['plugin_root'] = $path;

			$return = call_user_func_array('Garradin\\Plugin\\' . $row->callback, [&$params, &$callback_return]);

			if (true === $return) {
				return true;
			}
		}

		return false;
	}

	static public function listModulesAndPlugins(bool $installable = false): array
	{
		$list = [];

		if ($installable) {
			foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 0;') as $m) {
				$list[$m->name] = ['module' => $m];
			}

			foreach (self::listInstallable() as $p) {
				$list[$p->name] = ['plugin'   => $p];
			}

			foreach (self::listInstalled() as $p) {
				if ($p->enabled) {
					continue;
				}

				$list[$p->name] = ['plugin'   => $p];
			}
		}
		else {
			foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 1;') as $m) {
				$list[$m->name] = ['module' => $m];
			}

			foreach (self::listInstalled() as $p) {
				if (!$p->enabled) {
					continue;
				}

				$list[$p->name] = ['plugin'   => $p];
			}
		}

		foreach ($list as &$item) {
			$c = isset($item['plugin']) ? $item['plugin'] : $item['module'];
			$item['icon_url'] = $c->icon_url();
			$item['name'] = $c->name;
			$item['label'] = $c->label;
			$item['description'] = $c->description;
			$item['author'] = $c->author;
			$item['url'] = $c->url;
			$item['config_url'] = $c->hasConfig() ? $c->url($c::CONFIG_FILE) : null;
			$item['readme_url'] = $c->hasFile($c::README_FILE) ? $c->url($c::README_FILE) : null;
			$item['enabled'] = $c->enabled;
			$item['installed'] = isset($item['plugin']) ? $c->exists() : true;
			$item['restrict_section'] = $c->restrict_section;
			$item['restrict_level'] = $c->restrict_level;
		}

		unset($item);

		usort($list, fn ($a, $b) => strnatcasecmp($a['label'], $b['label']));

		return $list;
	}

	static public function listModulesAndPluginsMenu(Session $session): array
	{
		$list = [];

		foreach (DB::getInstance()->get('SELECT name, label, restrict_section, restrict_level FROM modules WHERE menu = 1;') as $m) {
			if ($m->restrict_section && !$session->canAccess($m->restrict_section, $m->restrict_level)) {
				continue;
			}

			$list['module_' . $m->name] = sprintf('<a href="%sm/%s/">%s</a>', ADMIN_URL, $m->name, $m->label);
		}

		foreach (DB::getInstance()->get('SELECT name, label, restrict_section, restrict_level FROM plugins WHERE menu = 1;') as $p) {
			if ($p->restrict_section && !$session->canAccess($p->restrict_section, $p->restrict_level)) {
				continue;
			}

			$list['plugin_' . $p->name] = sprintf('<a href="%sp/%s/">%s</a>', ADMIN_URL, $p->name, $p->label);
		}

		self::fireSignal('menu.item', compact('session'), $list);

		ksort($list);
		return $list;
	}

	static public function listModulesAndPluginsHomeButtons(Session $session): array
	{
		$list = [];

		foreach (DB::getInstance()->get('SELECT name, label, restrict_section, restrict_level FROM modules WHERE menu = 1;') as $m) {
			if ($m->restrict_section && !$session->canAccess($m->restrict_section, $m->restrict_level)) {
				continue;
			}

			$url = ADMIN_URL . 'm/' . $m->name . '/';
			$list[$m->name] = CommonFunctions::linkButton([
				'label' => $m->label,
				'icon' => $url . 'icon.svg',
				'href' => $url,
			]);
		}

		foreach (Modules::snippets(Modules::SNIPPET_HOME_BUTTON) as $name => $v) {
			$list[$name] = $v;
		}

		foreach (DB::getInstance()->get('SELECT name, label, restrict_section, restrict_level FROM plugins WHERE menu = 1;') as $p) {
			if ($p->restrict_section && !$session->canAccess($p->restrict_section, $p->restrict_level)) {
				continue;
			}

			$url = ADMIN_URL . 'p/' . $p->name . '/';
			$list[$p->name] = CommonFunctions::linkButton([
				'label' => $p->label,
				'icon' => $url . 'icon.svg',
				'href' => $url,
			]);
		}

		Plugins::fireSignal('home.button', ['user' => $session->getUser(), 'session' => $session], $list);

		ksort($list);
		return $list;
	}

	static public function get(string $name): ?Plugin
	{
		return EM::findOne(Plugin::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name);
	}

	static public function listInstalled(): array
	{
		return EM::getInstance(Plugin::class)->all('SELECT * FROM @TABLE ORDER BY label COLLATE NOCASE ASC;');
	}

	/**
	 * Liste les plugins téléchargés mais non installés
	 */
	static public function listInstallable(): array
	{
		$list = [];
		$exists = DB::getInstance()->getAssoc('SELECT name, name FROM plugins;');

		foreach (glob(PLUGINS_ROOT . '/*') as $file)
		{
			if (substr($file, 0, 1) == '.') {
				continue;
			}

			if (is_dir($file) && file_exists($file . '/' . Plugin::META_FILE)) {
				$file = basename($file);
				$name = $file;
			}
			elseif (substr($file, -7) == '.tar.gz') {
				$file = basename($file);
				$name = substr($file, 0, -7);
			}
			else {
				continue;
			}

			// Ignore existing plugins
			if (in_array($name, $exists)) {
				continue;
			}

			$list[$file] = null;

			try {
				$p = new Plugin;
				$p->name = $name;
				$p->updateFromINI();
				$p->selfCheck();
				$list[$name] = $p;
			}
			catch (ValidationException $e) {
				$list[$name] = $file . ': ' . $e->getMessage();
			}
		}

		ksort($list);

		return $list;
	}

	static public function install(string $name): void
	{
		$plugin = self::get($name);

		if ($plugin) {
			$plugin->set('enabled', true);
			$plugin->save();
			return;
		}

		$p = new Plugin;
		$p->name = $name;

		if (!$p->hasFile($p::META_FILE)) {
			throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Paheko : fichier plugin.ini manquant.', $name));
		}

		$p->updateFromINI();

		$db = DB::getInstance();
		$db->begin();
		$p->set('enabled', true);
		$p->save();

		if ($p->hasFile($p::INSTALL_FILE)) {
			$p->call($p::INSTALL_FILE, true);
		}

		$db->commit();
	}

	/**
	 * Upgrade all plugins if required
	 * This is run after an upgrade, a database restoration, or in the Plugins page
	 */
	static public function upgradeAllIfRequired(): bool
	{
		$i = 0;

		foreach (self::listInstalled() as $plugin) {
			// Ignore plugins if code is no longer available
			if (!$plugin->isAvailable()) {
				continue;
			}

			if ($plugin->needUpgrade()) {
				$plugin->upgrade();
				$i++;
			}

			unset($plugin);
		}

		return $i > 0;
	}
}

Modified src/include/lib/Garradin/Sauvegarde.php from [02a4118000] to [59a4725000].

524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
			// If logged-in user no longer exists, then login to first admin account
			if (!$session->refresh()) {
				$session->forceLogin(-1);
				$return |= self::CHANGED_USER;
			}

			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugin::upgradeAllIfRequired();
		}

		return $return;
	}

	public function restore(string $file)
	{







|







524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
			// If logged-in user no longer exists, then login to first admin account
			if (!$session->refresh()) {
				$session->forceLogin(-1);
				$return |= self::CHANGED_USER;
			}

			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugins::upgradeAllIfRequired();
		}

		return $return;
	}

	public function restore(string $file)
	{

Modified src/include/lib/Garradin/Services/Reminders.php from [90a6e75124] to [8c425b7b83].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Users\DynamicFields;
use Garradin\Email\Emails;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;







|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugins;
use Garradin\Utils;
use Garradin\Users\DynamicFields;
use Garradin\Email\Emails;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,
		]);

		Plugin::fireSignal('reminder.send.after', $reminder);

		return true;
	}

	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès







|







123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,
		]);

		Plugins::fireSignal('reminder.send.after', $reminder);

		return true;
	}

	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès

Modified src/include/lib/Garradin/Template.php from [e64ccb93a9] to [a293f28ead].

581
582
583
584
585
586
587







588

589
590
591
592
593
594
595
596
597
598
599
600

		$out .= '</table>';
		return $out;
	}

	protected function displayPermissions(array $params): string
	{







		$perms = $params['permissions'];


		$out = [];

		foreach (Category::PERMISSIONS as $name => $config) {
			$access = $perms->{'perm_' . $name};
			$label = sprintf('%s : %s', $config['label'], $config['options'][$access]);
			$out[$name] = sprintf('<b class="access_%s %s" title="%s">%s</b>', $access, $name, htmlspecialchars($label), $config['shape']);
		}

		return implode(' ', $out);
	}
}







>
>
>
>
>
>
>
|
>
|
<

|








581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597

598
599
600
601
602
603
604
605
606
607

		$out .= '</table>';
		return $out;
	}

	protected function displayPermissions(array $params): string
	{
		$out = [];

		if (isset($params['section'], $params['level'])) {
			$list = [$params['section'] => Category::PERMISSIONS[$params['section']]];
			$perms = (object) ['perm_' . $params['section'] => $params['level']];
		}
		else {
			$perms = $params['permissions'];
			$list = Category::PERMISSIONS;
		}


		foreach ($list as $name => $config) {
			$access = $perms->{'perm_' . $name};
			$label = sprintf('%s : %s', $config['label'], $config['options'][$access]);
			$out[$name] = sprintf('<b class="access_%s %s" title="%s">%s</b>', $access, $name, htmlspecialchars($label), $config['shape']);
		}

		return implode(' ', $out);
	}
}

Modified src/include/lib/Garradin/Upgrade.php from [c62ef681a0] to [71ae118229].

49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

	static public function upgrade()
	{
		$db = DB::getInstance();
		$backup = new Sauvegarde;
		$v = $db->version();

		Plugin::toggleSignals(false);

		Static_Cache::store('upgrade', 'Updating');

		// Créer une sauvegarde automatique
		$backup_file = sprintf(DATA_ROOT . '/association.pre_upgrade-%s.sqlite', garradin_version());
		$backup->make($backup_file);








|







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

	static public function upgrade()
	{
		$db = DB::getInstance();
		$backup = new Sauvegarde;
		$v = $db->version();

		Plugins::toggleSignals(false);

		Static_Cache::store('upgrade', 'Updating');

		// Créer une sauvegarde automatique
		$backup_file = sprintf(DATA_ROOT . '/association.pre_upgrade-%s.sqlite', garradin_version());
		$backup->make($backup_file);

148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
				require ROOT . '/include/migrations/1.2/1.2.2.php';
			}

			if (version_compare($v, '1.3.0', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0.php';
			}

			Plugin::upgradeAllIfRequired();

			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);







|







148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
				require ROOT . '/include/migrations/1.2/1.2.2.php';
			}

			if (version_compare($v, '1.3.0', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0.php';
			}

			Plugins::upgradeAllIfRequired();

			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);

Modified src/include/lib/Garradin/UserTemplate/Modules.php from [f759d31bbe] to [e1712e8d0b].

132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
	}

	static public function snippets(string $snippet, array $variables = []): array
	{
		$out = [];

		foreach (self::listForSnippet($snippet) as $module) {
			$out[] = $module->fetch($snippet, $variables);
		}

		return $out;
	}

	static public function listForSnippet(string $snippet): array
	{
		return EM::getInstance(Module::class)->all('SELECT f.* FROM @TABLE f
			INNER JOIN modules_templates t ON t.id_module = f.id
			WHERE t.name = ? AND f.enabled = 1
			ORDER BY f.label COLLATE NOCASE ASC;', $snippet);
	}

	static public function listModulesAndPluginsMenu(): array
	{
		$list = [];
		$session = Session::getInstance();

		foreach (DB::getInstance()->get('SELECT name, label, restrict_section, restrict_level FROM modules WHERE menu = 1;') as $m) {
			if (!$session->canAccess($m->restrict_section, $m->restrict_level)) {
				continue;
			}

			$list[$m->name] = sprintf('<a href="%sm/%s">%s</a>', ADMIN_URL, $m->name, $m->label);
		}

		foreach (DB::getInstance()->get('SELECT id, label, restrict_section, restrict_level FROM plugins WHERE menu = 1;') as $p) {
			if (!$session->canAccess($p->restrict_section, $p->restrict_level)) {
				continue;
			}

			$list[$m->name] = sprintf('<a href="%sp/%s">%s</a>', ADMIN_URL, $p->name, $p->label);
		}

		ksort($list);
		return $list;
	}

	static public function get(string $name): ?Module
	{
		return EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name);
	}

	static public function isEnabled(string $name): bool
	{
		return (bool) EM::getInstance(Module::class)->col('SELECT 1 FROM @TABLE WHERE name = ? AND enabled = 1;', $name);
	}
}







|


|










<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<










132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152

























153
154
155
156
157
158
159
160
161
162
	}

	static public function snippets(string $snippet, array $variables = []): array
	{
		$out = [];

		foreach (self::listForSnippet($snippet) as $module) {
			$out[$module->name] = $module->fetch($snippet, $variables);
		}

		return array_filter($out, fn($a) => trim($a) !== '');
	}

	static public function listForSnippet(string $snippet): array
	{
		return EM::getInstance(Module::class)->all('SELECT f.* FROM @TABLE f
			INNER JOIN modules_templates t ON t.id_module = f.id
			WHERE t.name = ? AND f.enabled = 1
			ORDER BY f.label COLLATE NOCASE ASC;', $snippet);
	}


























	static public function get(string $name): ?Module
	{
		return EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name);
	}

	static public function isEnabled(string $name): bool
	{
		return (bool) EM::getInstance(Module::class)->col('SELECT 1 FROM @TABLE WHERE name = ? AND enabled = 1;', $name);
	}
}

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [fae41f22ea] to [3f0c556abf].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\Translate;

use Garradin\Config;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Users\Session;

use Garradin\Entities\Files\File;
use Garradin\Files\Files;










|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\Translate;

use Garradin\Config;
use Garradin\Plugins;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Users\Session;

use Garradin\Entities\Files\File;
use Garradin\Files\Files;

109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
			}
		}

		$this->assignArray(self::getRootVariables());

		$this->registerAll();

		Plugin::fireSignal('usertemplate.init', ['template' => $this]);
	}

	/**
	 * Toggle safe mode
	 *
	 * If set to TRUE, then all functions and sections are removed, except foreach.
	 * Only modifiers can be used.







|







109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
			}
		}

		$this->assignArray(self::getRootVariables());

		$this->registerAll();

		Plugins::fireSignal('usertemplate.init', ['template' => $this]);
	}

	/**
	 * Toggle safe mode
	 *
	 * If set to TRUE, then all functions and sections are removed, except foreach.
	 * Only modifiers can be used.
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381

		if (!$is_web && $type != 'text/html' || !empty($this->_variables[0]['nocache'])) {
			$cache_as_uri = null;
		}

		if ($is_web && $type == 'text/html') {
			$scripts = [];
			Plugin::fireSignal('usertemplate.appendscript', ['template' => $this, 'content' => $content], $scripts);

			if (count($scripts)) {
				$scripts = array_map(fn($a) => sprintf('<script type="text/javascript" defer src="%s"></script>', $a), $scripts);
				$scripts = implode("\n", $scripts);
				$content = str_ireplace('</body', $scripts . '</body', $content);
			}
		}







|







367
368
369
370
371
372
373
374
375
376
377
378
379
380
381

		if (!$is_web && $type != 'text/html' || !empty($this->_variables[0]['nocache'])) {
			$cache_as_uri = null;
		}

		if ($is_web && $type == 'text/html') {
			$scripts = [];
			Plugins::fireSignal('usertemplate.appendscript', ['template' => $this, 'content' => $content], $scripts);

			if (count($scripts)) {
				$scripts = array_map(fn($a) => sprintf('<script type="text/javascript" defer src="%s"></script>', $a), $scripts);
				$scripts = implode("\n", $scripts);
				$content = str_ireplace('</body', $scripts . '</body', $content);
			}
		}

Modified src/include/lib/Garradin/Users/Session.php from [4b67ea0f8f] to [18019a2edb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\Log;
use Garradin\Utils;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\ValidationException;

use Garradin\Users\Users;
use Garradin\Email\Templates as EmailsTemplates;
use Garradin\Files\Files;









|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\Log;
use Garradin\Utils;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\ValidationException;

use Garradin\Users\Users;
use Garradin\Email\Templates as EmailsTemplates;
use Garradin\Files\Files;

95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
			$this->http = new \KD2\HTTP;
		}

		// Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect
		// notamment en installation mutualisée c'est plus efficace
		$return = ['is_compromised' => null];

		if (Plugin::fireSignal('password.check', ['password' => $password], $return) && isset($return['is_compromised'])) {
			return (bool) $return['is_compromised'];
		}

		return parent::isPasswordCompromised($password);
	}

	protected function getUserForLogin($login)







|







95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
			$this->http = new \KD2\HTTP;
		}

		// Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect
		// notamment en installation mutualisée c'est plus efficace
		$return = ['is_compromised' => null];

		if (Plugins::fireSignal('password.check', ['password' => $password], $return) && isset($return['is_compromised'])) {
			return (bool) $return['is_compromised'];
		}

		return parent::isPasswordCompromised($password);
	}

	protected function getUserForLogin($login)
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
		elseif ($user = $this->getUserForLogin($login)) {
			Log::add(Log::LOGIN_FAIL, compact('user_agent'), $user->id);
		}
		else {
			Log::add(Log::LOGIN_FAIL, compact('user_agent'));
		}

		Plugin::fireSignal('user.login', compact('login', 'password', 'remember_me', 'success'));

		// Clean up logs
		Log::clean();

		return $success;
	}








|







232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
		elseif ($user = $this->getUserForLogin($login)) {
			Log::add(Log::LOGIN_FAIL, compact('user_agent'), $user->id);
		}
		else {
			Log::add(Log::LOGIN_FAIL, compact('user_agent'));
		}

		Plugins::fireSignal('user.login', compact('login', 'password', 'remember_me', 'success'));

		// Clean up logs
		Log::clean();

		return $success;
	}

259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
			// Mettre à jour la date de connexion
			$this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$user_id]);
		}
		else {
			Log::add(Log::LOGIN_FAIL, $details, $user_id);
		}

		Plugin::fireSignal('user.login.otp', compact('success', 'user_id'));

		return $success;
	}

	public function logout(bool $all = false)
	{
		$this->_user = null;







|







259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
			// Mettre à jour la date de connexion
			$this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$user_id]);
		}
		else {
			Log::add(Log::LOGIN_FAIL, $details, $user_id);
		}

		Plugins::fireSignal('user.login.otp', compact('success', 'user_id'));

		return $success;
	}

	public function logout(bool $all = false)
	{
		$this->_user = null;

Modified src/include/lib/Garradin/Utils.php from [4ccd3ce76f] to [61432880f7].

1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
            return;
        }

        if (PDF_COMMAND == 'auto') {
            // Try to see if there's a plugin
            $in = ['string' => $str];

            if (Plugin::fireSignal('pdf.stream', $in)) {
                return;
            }

            unset($in);
        }

        // Only Prince handles using STDIN and STDOUT







|







1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
            return;
        }

        if (PDF_COMMAND == 'auto') {
            // Try to see if there's a plugin
            $in = ['string' => $str];

            if (Plugins::fireSignal('pdf.stream', $in)) {
                return;
            }

            unset($in);
        }

        // Only Prince handles using STDIN and STDOUT
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
        $str = self::appendCookieToURLs($str);
        file_put_contents($source, $str);

        if ($cmd == 'auto') {
            // Try to see if there's a plugin
            $in = ['source' => $source, 'target' => $target];

            if (Plugin::fireSignal('pdf.create', $in)) {
                Utils::safe_unlink($source);
                return $target;
            }

            unset($in);

            // Try to find a local executable







|







1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
        $str = self::appendCookieToURLs($str);
        file_put_contents($source, $str);

        if ($cmd == 'auto') {
            // Try to see if there's a plugin
            $in = ['source' => $source, 'target' => $target];

            if (Plugins::fireSignal('pdf.create', $in)) {
                Utils::safe_unlink($source);
                return $target;
            }

            unset($in);

            // Try to find a local executable

Modified src/include/lib/Garradin/Web/Render/Skriv.php from [0bb5787801] to [c9c1334af0].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

use Garradin\Plugin;
use Garradin\UserTemplate\CommonModifiers;

use KD2\SkrivLite;
use KD2\Garbage2xhtml;

use const Garradin\{ADMIN_URL, WWW_URL};







|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

use Garradin\Plugins;
use Garradin\UserTemplate\CommonModifiers;

use KD2\SkrivLite;
use KD2\Garbage2xhtml;

use const Garradin\{ADMIN_URL, WWW_URL};

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
		$this->skriv = new SkrivLite;
		$this->skriv->registerExtension('file', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('fichier', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('image', [$this, 'SkrivImage']);
		$this->skriv->registerExtension('html', [$this, 'SkrivHTML']);

		// Enregistrer d'autres extensions éventuellement
		Plugin::fireSignal('skriv.init', ['skriv' => $this->skriv]);
	}

	public function render(?string $content = null): string
	{
		$skriv =& $this->skriv;

		$str = $content ?? $this->file->fetch();







|







24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
		$this->skriv = new SkrivLite;
		$this->skriv->registerExtension('file', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('fichier', [$this, 'SkrivFile']);
		$this->skriv->registerExtension('image', [$this, 'SkrivImage']);
		$this->skriv->registerExtension('html', [$this, 'SkrivHTML']);

		// Enregistrer d'autres extensions éventuellement
		Plugins::fireSignal('skriv.init', ['skriv' => $this->skriv]);
	}

	public function render(?string $content = null): string
	{
		$skriv =& $this->skriv;

		$str = $content ?? $this->file->fetch();

Modified src/include/lib/Garradin/Web/Router.php from [3220c288b9] to [69c353f36d].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace Garradin\Web;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Files\WebDAV\Server as WebDAV_Server;

use Garradin\Web\Skeleton;
use Garradin\Web\Web;

use Garradin\API;
use Garradin\Config;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\Utils;

use Garradin\Users\Session;

use const Garradin\{WWW_URI, ADMIN_URL, ROOT, HTTP_LOG_FILE, ENABLE_XSENDFILE};














|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace Garradin\Web;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Files\WebDAV\Server as WebDAV_Server;

use Garradin\Web\Skeleton;
use Garradin\Web\Web;

use Garradin\API;
use Garradin\Config;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Utils;

use Garradin\Users\Session;

use const Garradin\{WWW_URI, ADMIN_URL, ROOT, HTTP_LOG_FILE, ENABLE_XSENDFILE};

69
70
71
72
73
74
75



76
77
78
79
80
81
82
83
84
85
86
		if ($uri == 'feed/atom/') {
			Utils::redirect('/atom.xml');
		}
		elseif ($uri == 'favicon.ico') {
			header('Location: ' . Config::getInstance()->fileURL('favicon'), true);
			return;
		}



		elseif (preg_match('!^(admin/p|p)/(' . Plugin::PLUGIN_ID_REGEXP . ')/(.*)$!', $uri, $match)) {
			$plugin = new Plugin($match[2]);
			$public = $match[1] == 'p';
			$plugin->route($public, $match[3]);
			return;
		}
		elseif ('admin' == $first || 'p' == $first) {
			http_response_code(404);
			throw new UserException('Cette page n\'existe pas.');
		}
		elseif ('api' == $first) {







>
>
>
|
|
|
|







69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
		if ($uri == 'feed/atom/') {
			Utils::redirect('/atom.xml');
		}
		elseif ($uri == 'favicon.ico') {
			header('Location: ' . Config::getInstance()->fileURL('favicon'), true);
			return;
		}
		elseif (preg_match('!^(?:admin/p|p|m)/\w+$!', $uri)) {
			Utils::redirect('/' . $uri . '/');
		}
		elseif (preg_match('!^(admin/p|p)/(' . Plugins::NAME_REGEXP . ')/(.*)$!', $uri, $match)) {
			$plugin = Plugins::get($match[2]);
			$uri = ($match[1] == 'admin/p' ? 'admin/' : '') . $match[3];
			$plugin->route($uri);
			return;
		}
		elseif ('admin' == $first || 'p' == $first) {
			http_response_code(404);
			throw new UserException('Cette page n\'existe pas.');
		}
		elseif ('api' == $first) {
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
						break;
					}
				}
			}

			$session = Session::getInstance();

			if (Plugin::fireSignal('http.request.file.before', compact('file', 'uri', 'session'))) {
				// If a plugin handled the request, let's stop here
				return;
			}

			if ($size) {
				$file->serveThumbnail($session, $size);
			}
			else {
				$file->serve($session, isset($_GET['download']), $_GET['s'] ?? null, $_POST['p'] ?? null);
			}

			Plugin::fireSignal('http.request.file.after', compact('file', 'uri', 'session'));

			return;
		}

		Skeleton::route($uri);
	}








|











|







109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
						break;
					}
				}
			}

			$session = Session::getInstance();

			if (Plugins::fireSignal('http.request.file.before', compact('file', 'uri', 'session'))) {
				// If a plugin handled the request, let's stop here
				return;
			}

			if ($size) {
				$file->serveThumbnail($session, $size);
			}
			else {
				$file->serve($session, isset($_GET['download']), $_GET['s'] ?? null, $_POST['p'] ?? null);
			}

			Plugins::fireSignal('http.request.file.after', compact('file', 'uri', 'session'));

			return;
		}

		Skeleton::route($uri);
	}

Modified src/include/lib/Garradin/Web/Skeleton.php from [b64003a79f] to [bf267b1aff].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

namespace Garradin\Web;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;
use Garradin\UserException;
use Garradin\UserTemplate\Modules;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Config;
use Garradin\Plugin;
use Garradin\Utils;

use KD2\Brindille_Exception;
use KD2\DB\EntityManager as EM;

use const Garradin\{ROOT, ADMIN_URL};












|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

namespace Garradin\Web;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;
use Garradin\UserException;
use Garradin\UserTemplate\Modules;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Config;
use Garradin\Plugins;
use Garradin\Utils;

use KD2\Brindille_Exception;
use KD2\DB\EntityManager as EM;

use const Garradin\{ROOT, ADMIN_URL};

98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
		}

		return null;
	}

	public function serve(string $uri, array $params = []): void
	{
		if (Plugin::fireSignal('http.request.skeleton.before', $params)) {
			return;
		}

		$type = $this->type();

		if (!$type) {
			throw new \InvalidArgumentException('Invalid skeleton type');







|







98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
		}

		return null;
	}

	public function serve(string $uri, array $params = []): void
	{
		if (Plugins::fireSignal('http.request.skeleton.before', $params)) {
			return;
		}

		$type = $this->type();

		if (!$type) {
			throw new \InvalidArgumentException('Invalid skeleton type');
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
		else {
			Cache::link($uri, $this->defaultPath());
			header(sprintf('Content-Type: %s;charset=utf-8', $type), true);
			readfile($this->defaultPath());
			flush();
		}

		Plugin::fireSignal('http.request.skeleton.after', $params);
	}

	public function file(): ?File
	{
		return Files::get(File::CONTEXT_SKELETON . '/' . $this->path);
	}








|







141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
		else {
			Cache::link($uri, $this->defaultPath());
			header(sprintf('Content-Type: %s;charset=utf-8', $type), true);
			readfile($this->defaultPath());
			flush();
		}

		Plugins::fireSignal('http.request.skeleton.after', $params);
	}

	public function file(): ?File
	{
		return Files::get(File::CONTEXT_SKELETON . '/' . $this->path);
	}

Modified src/include/migrations/1.3/1.3.0.php from [0549342cfa] to [3c83dc2666].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
















15
16
17
18
19
20
21
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

$db->beginSchemaUpdate();

// Get old keys
$config = (object) $db->getAssoc('SELECT key, value FROM config WHERE key IN (\'champs_membres\', \'champ_identifiant\', \'champ_identite\');');

// Create config_users_fields table, and lots of stuff
$db->import(ROOT . '/include/migrations/1.3/schema.sql');

















// Migrate users table
$df = \Garradin\Users\DynamicFields::fromOldINI($config->champs_membres, $config->champ_identifiant, $config->champ_identite, 'numero');
$df->save(false);

// Migrate other stuff
$db->import(ROOT . '/include/migrations/1.3/1.3.0.sql');












|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

$db->beginSchemaUpdate();

// Get old keys
$config = (object) $db->getAssoc('SELECT key, value FROM config WHERE key IN (\'champs_membres\', \'champ_identifiant\', \'champ_identite\');');

// Create config_users_fields table
$db->exec('
CREATE TABLE IF NOT EXISTS config_users_fields (
    id INTEGER NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    sort_order INTEGER NOT NULL,
    type TEXT NOT NULL,
    label TEXT NOT NULL,
    help TEXT NULL,
    required INTEGER NOT NULL DEFAULT 0,
    read_access INTEGER NOT NULL DEFAULT 0,
    write_access INTEGER NOT NULL DEFAULT 1,
    list_table INTEGER NOT NULL DEFAULT 0,
    options TEXT NULL,
    default_value TEXT NULL,
    sql TEXT NULL,
    system TEXT NULL
);');

// Migrate users table
$df = \Garradin\Users\DynamicFields::fromOldINI($config->champs_membres, $config->champ_identifiant, $config->champ_identite, 'numero');
$df->save(false);

// Migrate other stuff
$db->import(ROOT . '/include/migrations/1.3/1.3.0.sql');

Modified src/include/migrations/1.3/1.3.0.sql from [8a8da01d86] to [99fcb50e59].

1
2
3
4
5
6
7
8
9
10
-- Already created before, so we need to drop it to migrate
DROP TABLE plugins_signals;

-- The new users table has already been created and copied
ALTER TABLE plugins RENAME TO plugins_old;
ALTER TABLE plugins_signaux RENAME TO plugins_signaux_old;

-- References old membres table
ALTER TABLE services_users RENAME TO services_users_old; -- Also take id_fee into account for unique key
ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
<
<
<










1
2
3
4
5
6
7



-- The new users table has already been created and copied
ALTER TABLE plugins RENAME TO plugins_old;
ALTER TABLE plugins_signaux RENAME TO plugins_signaux_old;

-- References old membres table
ALTER TABLE services_users RENAME TO services_users_old; -- Also take id_fee into account for unique key
ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
52
53
54
55
56
57
58

59



60
61
62
63
64
65
66
DROP TABLE services_users_old;

-- Remove old plugin as it cannot be uninstalled as it no longer exists
DELETE FROM plugins_old WHERE nom = 'ouvertures';
DELETE FROM plugins_signaux_old WHERE plugin = 'ouvertures';

-- Rename plugins table columns to English

INSERT INTO plugins (id, label, description, author, url, version, config, menu) SELECT id, nom, description, auteur, url, version, config, 0 FROM plugins_old;



INSERT INTO plugins_signals SELECT * FROM plugins_signaux_old;

DROP TABLE plugins_signaux_old;
DROP TABLE plugins_old;

INSERT INTO searches SELECT * FROM recherches;
UPDATE searches SET target = 'accounting' WHERE target = 'compta';







>
|
>
>
>







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
DROP TABLE services_users_old;

-- Remove old plugin as it cannot be uninstalled as it no longer exists
DELETE FROM plugins_old WHERE nom = 'ouvertures';
DELETE FROM plugins_signaux_old WHERE plugin = 'ouvertures';

-- Rename plugins table columns to English
INSERT INTO plugins (name, label, description, author, url, version, config, enabled, menu, restrict_level, restrict_section)
	SELECT id, nom, description, auteur, url, version, config, 1, menu,
	CASE WHEN menu_condition IS NOT NULL THEN 2 ELSE NULL END,
	CASE WHEN menu_condition IS NOT NULL THEN 'users' ELSE NULL END
	FROM plugins_old;
INSERT INTO plugins_signals SELECT * FROM plugins_signaux_old;

DROP TABLE plugins_signaux_old;
DROP TABLE plugins_old;

INSERT INTO searches SELECT * FROM recherches;
UPDATE searches SET target = 'accounting' WHERE target = 'compta';

Modified src/include/migrations/1.3/schema.sql from [aef26e336a] to [c3e1dca3f5].

25
26
27
28
29
30
31
32
33

34
35
36
37
38
39
40
41
42

43


44
45
46
47
48
49
50
51
52
53





























54
55
56
57
58
59
60
    system TEXT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,

    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INT NOT NULL DEFAULT 0,
    home_button INT NOT NULL DEFAULT 0,
    restrict_section TEXT NULL,
    restrict_level INT NULL,
    config TEXT NULL

);



CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);






























CREATE TABLE IF NOT EXISTS api_credentials
(
    id INTEGER NOT NULL PRIMARY KEY,
    label TEXT NOT NULL,
    key TEXT NOT NULL,
    secret TEXT NOT NULL,
    created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,







|

>








|
>

>
>





|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
    system TEXT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);

CREATE TABLE IF NOT EXISTS plugins
(
    id INTEGER NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    label TEXT NOT NULL,
    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INT NOT NULL DEFAULT 0,
    home_button INT NOT NULL DEFAULT 0,
    restrict_section TEXT NULL,
    restrict_level INT NULL,
    config TEXT NULL,
    enabled INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name);

CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (name),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS modules
-- List of modules
(
    id INTEGER NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    label TEXT NOT NULL,
    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    menu INT NOT NULL DEFAULT 0,
    home_button INT NOT NULL DEFAULT 0,
    restrict_section TEXT NULL,
    restrict_level INT NULL,
    config TEXT NULL,
    enabled INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);

CREATE TABLE IF NOT EXISTS modules_templates
-- List of forms special templates
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
    name TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);

CREATE TABLE IF NOT EXISTS api_credentials
(
    id INTEGER NOT NULL PRIMARY KEY,
    label TEXT NOT NULL,
    key TEXT NOT NULL,
    secret TEXT NOT NULL,
    created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);

CREATE TABLE IF NOT EXISTS modules
-- List of modules
(
    id INTEGER NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    label TEXT NOT NULL,
    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    menu INT NOT NULL DEFAULT 0,
    home_button INT NOT NULL DEFAULT 0,
    restrict_section TEXT NULL,
    restrict_level INT NULL,
    config TEXT NULL,
    enabled INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);

CREATE TABLE IF NOT EXISTS modules_templates
-- List of forms special templates
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
    name TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
523
524
525
526
527
528
529






























CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);





























Modified src/scripts/cron.php from [4119a18488] to [3b05c3796a].

19
20
21
22
23
24
25
26
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugin::fireSignal('cron');







|
19
20
21
22
23
24
25
26
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugins::fireSignal('cron');

Modified src/skel-dist/modules/recus_fiscaux/module.ini from [5d6ae17b31] to [be4230dbff].

1
2
3
4



name="Reçus fiscaux"
description="Permet de générer des reçus fiscaux. Conforme aux exigences fiscales de 2022. Seuls les membres ayant accès à la comptabilité auront accès à ce module."
author="Paheko"
url="https://paheko.cloud/"




|


>
>
>
1
2
3
4
5
6
7
name="Reçus fiscaux"
description="Permet de générer des reçus fiscaux. Conforme aux exigences fiscales de 2022."
author="Paheko"
url="https://paheko.cloud/"
home_button=true
restrict_section="accounting"
restrict_level="read"

Modified src/templates/config/_menu.tpl from [e93a463d0d] to [48f28499f7].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{if !$dialog}
<nav class="tabs">
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Configuration</a></li>
		<li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}config/users/">Membres</a></li>
		<li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li>
		<li{if $current == 'modules'} class="current"{/if}><a href="{$admin_url}config/modules/">Modules</a></li>
		<li{if $current == 'plugins'} class="current"{/if}><a href="{$admin_url}config/plugins.php">Extensions</a></li>
		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'users'}
	<ul class="sub">
		<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/users/">Préférences</a></li>
		<li{if $sub_current == 'fields'} class="current"{/if}><a href="{$admin_url}config/fields/">Fiche des membres</a></li>







|
<







1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
{if !$dialog}
<nav class="tabs">
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Configuration</a></li>
		<li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}config/users/">Membres</a></li>
		<li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li>
		<li{if $current == 'ext'} class="current"{/if}><a href="{$admin_url}config/ext/">Extensions</a></li>

		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'users'}
	<ul class="sub">
		<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/users/">Préférences</a></li>
		<li{if $sub_current == 'fields'} class="current"{/if}><a href="{$admin_url}config/fields/">Fiche des membres</a></li>

Added src/templates/config/ext/delete.tpl version [af1fe35dc5].



































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{include file="_head.tpl" title="Désinstaller une extension" current="config"}

{if $plugin}
	{include file="common/delete_form.tpl"
		legend="Supprimer une extension"
		confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension"
		warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$plugin.label
		alert="Attention, cela supprimera toutes les données liées à l'extension !"}
{else}
	{include file="common/delete_form.tpl"
		legend="Supprimer une extension"
		confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension"
		warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$module.label
		alert="Attention, cela supprimera toutes les données liées à l'extension, y compris les modifications apportées !"}
{/if}

{include file="_foot.tpl"}

Added src/templates/config/ext/index.tpl version [8c95737e3b].























































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
{include file="_head.tpl" title="Extensions" current="config"}

{include file="config/_menu.tpl" current="ext"}

<nav class="tabs">

	<ul class="sub">
		<li{if !$installable} class="current"{/if}><a href="./">Activées</a></li>
		<li{if $installable} class="current"{/if}><a href="./?install=1">Inactives</a></li>
	</ul>
</nav>

{if !empty($url_plugins)}
<p class="actions">
	{linkbutton shape="help" href=$url_plugins label="Trouver d'autres extensions à installer" target="_blank"}
</p>
{/if}


{form_errors}

<form method="post" action="">
	<table class="list">
		<thead>
			<td></td>
			<td>Extension</td>
			<td>Accès restreint</td>
			<td></td>
			<td></td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="item"}
			<tr {if $_GET.focus == $item.name}class="highlight"{/if}>
				<td class="icon">
					{if $item.icon_url}
					<svg>
						<use xlink:href='{$item.icon_url}#img' href="{$item.icon_url}#img"></use>
					</svg>
					{/if}
				</td>
				<td>
					<h3>{$item.label}
						{if $item.module && $item.module->canDelete()}
							<strong class="tag">Modifiée</strong>
						{elseif $item.module}
							<span class="tag">Modifiable</span>
						{/if}
					</h3>
					<small>{$item.description|escape|nl2br}</small><br />
					<small class="help">
						Par {link label=$item.author href=$item.url target="_blank"}
						{if $item.plugin && $item.plugin.version}— Version {$item.plugin.version}{/if}
						{if $item.readme_url}
							— {link href=$item.readme_url label="Documentation" target="_dialog"}
						{/if}
					</small>
				</td>
				<td>
					{if $item.restrict_section}
						<span class="permissions">{display_permissions section=$item.restrict_section level=$item.restrict_level}</span>
					{/if}
				</td>
				<td class="actions">
					{if $item.module && $item.enabled}
						{if $item.module->hasLocal() && $item.module->hasDist()}
							{linkbutton label="Remettre à zéro" href="delete.php?module=%s"|args:$item.name shape="reset" target="_dialog"}
						{/if}
						{*FIXME{linkbutton label="Modifier" href="edit.php?module=%s"|args:$item.name shape="edit" target="_dialog"}*}
					{elseif $item.module && !$item.enabled && $item.module->canDelete()}
						{linkbutton label="Supprimer" href="delete.php?module=%s"|args:$item.name shape="delete" target="_dialog"}
					{elseif $item.plugin && !$item.enabled && $item.installed}
						{linkbutton label="Supprimer" href="delete.php?plugin=%s"|args:$item.name shape="delete" target="_dialog"}
					{/if}
				</td>
				<td class="actions">
					{if $item.config_url && $item.enabled}
						{linkbutton label="Configurer" href=$item.config_url shape="settings" target="_dialog"}
					{/if}
				</td>
				<td class="actions">
					{if $item.module}
						{if $item.enabled}
							{button type="submit" label="Désactiver" shape="eye-off" name="disable_module" value=$item.name}
						{else}
							{button type="submit" label="Activer" shape="eye" name="enable" value=$item.name}
						{/if}
					{else}
						{if $item.enabled}
							{button type="submit" label="Désactiver" shape="eye-off" name="disable_plugin" value=$item.name}
						{else}
							{button type="submit" label="Activer" shape="eye" name="install" value=$item.name}
						{/if}
					{/if}
				</td>
			</tr>
			{/foreach}
		</tbody>
	</table>
	{csrf_field key=$csrf_key}
</form>

<p class="help">
	La mention <em class="tag">Modifiable</em> indique que cette extension est un module que vous pouvez modifier. {linkbutton shape="help" label="Documentation des modules" href=$url_help_modules target="_dialog"}
</p>

{include file="_foot.tpl"}

Deleted src/templates/config/modules/index.tpl version [14240b61b4].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
{include file="_head.tpl" title="Modules" current="config"}

{include file="config/_menu.tpl" current="modules"}

<table class="list">
	<tbody>
		{foreach from=$list item="module"}
		<tr>
			<td class="icon">
				{if $url = $module->icon_url()}
				<svg>
					<use xlink:href='{$url}#img' href="{$url}#img"></use>
				</svg>
				{/if}
			</td>
			<td><h3>{$module.label}</h3>{$module.description|escape|nl2br}</td>
			<td class="actions">
				{*{linkbutton label="Modifier" href="edit.php?module=%s"|args:$module.name shape="edit" target="_dialog"}*}
				{if $module->hasConfig() && $module.enabled}
					{linkbutton label="Configurer" href=$module->url($module::CONFIG_TEMPLATE) shape="settings" target="_dialog"}
				{/if}
				{if $module->canDelete()}
					{if $module->hasDist()}
						{linkbutton label="Remettre à zéro" href="delete.php?module=%s"|args:$module.name shape="reset" target="_dialog"}
					{else}
						{linkbutton label="Supprimer" href="delete.php?module=%s"|args:$module.name shape="delete" target="_dialog"}
					{/if}
				{/if}
			</td>
			<td class="actions">
				{if $module.enabled}
					{linkbutton label="Désactiver" shape="eye-off" href="?disable=%s"|args:$module.name}
				{else}
					{linkbutton label="Activer" shape="eye" href="?enable=%s"|args:$module.name}
				{/if}
			</td>
		</tr>
		{/foreach}
	</tbody>
</table>

{include file="_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




















































































Modified src/www/admin/_inc.php from [0cfa0f9f9d] to [cb565f2004].

55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
	}

	$tpl->assign('current', '');

	if ($session->get('plugins_menu') === null) {
		// Construction de la liste de plugins pour le menu
		// et stockage en session pour ne pas la recalculer à chaque page
		$session->set('plugins_menu', Plugin::listMenu($session));
	}

	$tpl->assign('plugins_menu', $session->get('plugins_menu'));
}

// Make sure we allow frames to work
header('X-Frame-Options: SAMEORIGIN', true);







|







55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
	}

	$tpl->assign('current', '');

	if ($session->get('plugins_menu') === null) {
		// Construction de la liste de plugins pour le menu
		// et stockage en session pour ne pas la recalculer à chaque page
		$session->set('plugins_menu', Plugins::listModulesAndPluginsMenu($session));
	}

	$tpl->assign('plugins_menu', $session->get('plugins_menu'));
}

// Make sure we allow frames to work
header('X-Frame-Options: SAMEORIGIN', true);

Added src/www/admin/config/ext/delete.php version [9d0842a19f].



























































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
namespace Garradin;

use Garradin\UserTemplate\Modules;
use Garradin\Plugins;

require_once __DIR__ . '/../_inc.php';

$csrf_key = 'ext_delete';
$plugin = $module = null;

if (qg('plugin')) {
	$plugin = Plugins::get(qg('plugin'));

	$form->runIf(f('delete') && f('confirm_delete'), function () use ($plugin) {
		$plugin->delete();
	}, $csrf_key, '!config/ext/');
}
else {
	$module = Modules::get(qg('module'));

	$form->runIf(f('delete') && f('confirm_delete'), function () use ($module) {
		$module->delete();
	}, $csrf_key, '!config/ext/');
}

$tpl->assign(compact('plugin', 'module', 'csrf_key'));

$tpl->display('config/ext/delete.tpl');

Added src/www/admin/config/ext/index.php version [2d25dc3169].































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php
namespace Garradin;

use Garradin\UserTemplate\Modules;
use Garradin\Plugins;

require_once __DIR__ . '/../_inc.php';

$csrf_key = 'ext';

$form->runIf('install', function () {
	Plugins::install(f('install'));
}, $csrf_key, '!config/ext/?focus=' . f('install'));

$form->runIf('enable', function () {
	$m = Modules::get(f('enable'));

	if (!$m) {
		throw new UserException('Ce module n\'existe pas');
	}

	$m->enabled = true;
	$m->save();
}, $csrf_key, '!config/ext/?focus=' . f('enable'));

$form->runIf('disable_module', function () {
	$m = Modules::get(f('disable_module'));

	if (!$m) {
		throw new UserException('Ce module n\'existe pas');
	}

	$m->enabled = false;
	$m->save();
}, $csrf_key, '!config/ext/');

$form->runIf('disable_plugin', function () {
	$p = Plugins::get(f('disable_plugin'));

	if (!$p) {
		throw new UserException('Cette extension n\'existe pas');
	}

	$p->set('enabled', false);
	$p->save();
}, $csrf_key, '!config/ext/');

Modules::refresh();

if (qg('install')) {
	$list = Plugins::listModulesAndPlugins(true);
	$tpl->assign('url_plugins', ENABLE_TECH_DETAILS ? WEBSITE . 'wiki?name=Extensions' : null);
	$tpl->assign('installable', true);
}
else {
	$list = Plugins::listModulesAndPlugins(false);
	$tpl->assign('installable', false);
}

$url_help_modules = sprintf(HELP_PATTERN_URL, 'modules');
$tpl->assign(compact('list', 'csrf_key', 'url_help_modules'));

$tpl->display('config/ext/index.tpl');

Deleted src/www/admin/config/modules/index.php version [10b00a00ec].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
namespace Garradin;

use Garradin\UserTemplate\Modules;

require_once __DIR__ . '/../_inc.php';

$form->runIf(qg('enable') !== null, function () {
	$m = Modules::get(qg('enable'));

	if (!$m) {
		throw new UserException('Ce module n\'existe pas');
	}

	$m->enabled = true;
	$m->save();
}, null, '!config/modules/');

$form->runIf(qg('disable') !== null, function () {
	$m = Modules::get(qg('disable'));

	if (!$m) {
		throw new UserException('Ce module n\'existe pas');
	}

	$m->enabled = false;
	$m->save();
}, null, '!config/modules/');

Modules::refresh();

$list = Modules::list();

$tpl->assign(compact('list'));

$tpl->display('config/modules/index.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































Modified src/www/admin/index.php from [3fd93a9cbf] to [461052c44c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace Garradin;

use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$banner = '';
$session = Session::getInstance();
Plugin::fireSignal('home.banner', ['user' => $session->getUser(), 'session' => $session], $banner);

$homepage = Config::getInstance()->file('admin_homepage');

if ($homepage) {
	$homepage = $homepage->render(ADMIN_URL . 'common/files/preview.php?p=' . File::CONTEXT_DOCUMENTS . '/');
}
else {
	$homepage = null;
}

$buttons = [];
Plugin::fireSignal('home.button', ['user' => $session->getUser(), 'session' => $session], $buttons);

foreach (Modules::snippets(Modules::SNIPPET_HOME_BUTTON) as $snippet) {
	if (trim($snippet) === '') {
		continue;
	}

	$buttons[] = $snippet;
}

$tpl->assign(compact('homepage', 'banner', 'buttons'));

$tpl->assign('custom_css', ['!web/css.php']);

$tpl->display('index.tpl');
flush();








|





|










|
<
<
<
<
<
<
<
<
<







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26









27
28
29
30
31
32
33
<?php

namespace Garradin;

use Garradin\Web\Web;
use Garradin\Files\Files;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\Plugins;

require_once __DIR__ . '/_inc.php';

$banner = '';
$session = Session::getInstance();
Plugins::fireSignal('home.banner', ['user' => $session->getUser(), 'session' => $session], $banner);

$homepage = Config::getInstance()->file('admin_homepage');

if ($homepage) {
	$homepage = $homepage->render(ADMIN_URL . 'common/files/preview.php?p=' . File::CONTEXT_DOCUMENTS . '/');
}
else {
	$homepage = null;
}

$buttons = Plugins::listModulesAndPluginsHomeButtons($session);










$tpl->assign(compact('homepage', 'banner', 'buttons'));

$tpl->assign('custom_css', ['!web/css.php']);

$tpl->display('index.tpl');
flush();

Modified src/www/admin/static/styles/02-common.css from [f674d04a55] to [7701523ea7].

641
642
643
644
645
646
647















    fill: rgb(var(--gTextColor));
    stroke: rgb(var(--gTextColor));
}

.print-only {
    display: none;
}






















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
    fill: rgb(var(--gTextColor));
    stroke: rgb(var(--gTextColor));
}

.print-only {
    display: none;
}

.tag {
    font-size: .8rem;
    font-weight: normal;
    background: rgba(var(--gSecondColor), 0.3);
    border-radius: .5em;
    padding: .2em .4em;
    display: inline-block;
    margin: 0 .2em;
}

strong.tag {
    background: rgba(var(--gMainColor), 0.7);
    color: rgb(var(--gBgColor));
}

Modified src/www/admin/static/styles/03-forms.css from [e0ead5d5d9] to [62bd0fac5d].

448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
    font-size: .9em;
}

.actions-center {
    text-align: center;
}

form p.actions {
    float: right;
    margin: .5em;
}

/** Datepicker widget */
.datepicker-parent {
    position: relative;







|







448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
    font-size: .9em;
}

.actions-center {
    text-align: center;
}

p.actions {
    float: right;
    margin: .5em;
}

/** Datepicker widget */
.datepicker-parent {
    position: relative;

Modified src/www/admin/static/styles/06-tables.css from [29141f5514] to [f36be1f178].

55
56
57
58
59
60
61




62
63
64
65
66
67
68
    background: inherit !important;
}

table.list tbody tr.checked {
    color: #633 !important;
    background: #ffc !important;
}





table.list .error {
    color: red;
    font-weight: bold;
}

table.list .alert {







>
>
>
>







55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
    background: inherit !important;
}

table.list tbody tr.checked {
    color: #633 !important;
    background: #ffc !important;
}

table.list tbody tr.highlight {
    box-shadow: 0px 0px 5px 5px rgba(var(--gSecondColor), 1);
}

table.list .error {
    color: red;
    font-weight: bold;
}

table.list .alert {

Deleted tools/make_plugin.php version [a9aa0fc1e3].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

if (empty($argv[1]) || empty($argv[2]))
{
    die("Usage : " . basename(__FILE__) . " /path/to/plugin/source /path/to/plugin/archive\n");
}

$plugin_file = $argv[2];
$plugin_file = preg_replace('/\.(?:tar(?:\.gz)?|phar)?$/', '', $plugin_file);

$target = realpath($argv[1]);

if (!file_exists($target . '/garradin_plugin.ini'))
{
	die("ERREUR : Le fichier $target/garradin_plugin.ini est introuvable.\n");
}

$infos = parse_ini_file($target . '/garradin_plugin.ini');

if (!empty($infos['config']))
{
	if (!file_exists($target . '/config.json'))
	{
		die("ERREUR : Le fichier config.json est obligatoire si config=1 dans garradin_plugin.ini.\n");
	}

	if (!file_exists($target . '/www/admin/config.php'))
	{
		die("ERREUR : Le fichier www/admin/config.php est obligatoire si config=1 dans garradin_plugin.ini.\n");
	}
}

$required = ['nom', 'description', 'auteur', 'url', 'version', 'menu', 'config'];

foreach ($required as $key)
{
	if (!array_key_exists($key, $infos))
	{
		die('ERREUR : Le fichier garradin_plugin.ini ne contient pas d\'entrée "'.$key.'".' . "\n");
	}
}

if (!empty($infos['menu']) && !file_exists($target . '/www/admin/index.php'))
{
	die("ERREUR : Le fichier www/admin/index.php est obligatoire quand menu=1\n");
}

@unlink('/tmp/plugin.tar');
@unlink('/tmp/plugin.tar.gz');

$p = new PharData('/tmp/plugin.tar');

$p->buildFromDirectory($target);

$p->compress(Phar::GZ);

@unlink('/tmp/plugin.tar');
rename('/tmp/plugin.tar.gz', $plugin_file . '.tar.gz');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<