Artifact 583c59a6d5054fd6c03281b757c8e921340f5f02de75230d430e0f59cf9a8f4f:


<?php

namespace Garradin\UserTemplate;

use KD2\Brindille;
use KD2\Brindille_Exception;
use KD2\ErrorManager;
use KD2\JSONSchema;
use KD2\Security;

use Garradin\Config;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Template;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\Email\Emails;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Module;
use Garradin\Entities\Email\Email;
use Garradin\Users\DynamicFields;
use Garradin\Users\Session;

use const Garradin\{ROOT, WWW_URL, SECRET_KEY};

class Functions
{
	const FUNCTIONS_LIST = [
		'include',
		'http',
		'debug',
		'error',
		'read',
		'save',
		'delete',
		'admin_header',
		'admin_footer',
		'password_input',
		'signature',
		'captcha',
		'mail',
		'button',
		'form_errors',
		'redirect',
	];

	const COMPILE_FUNCTIONS_LIST = [
		':break' => [self::class, 'break'],
		':continue' => [self::class, 'continue'],
	];

	/**
	 * Compile function to break inside a loop
	 */
	static public function break(string $name, string $params, Brindille $tpl, int $line)
	{
		$in_loop = false;
		foreach ($tpl->_stack as $element) {
			if ($element[0] == $tpl::SECTION) {
				$in_loop = true;
				break;
			}
		}

		if (!$in_loop) {
			throw new Brindille_Exception(sprintf('Error on line %d: break can only be used inside a section', $line));
		}

		return '<?php break; ?>';
	}

	/**
	 * Compile function to continue inside a loop
	 */
	static public function continue(string $name, string $params, Brindille $tpl, int $line)
	{
		$in_loop = false;
		foreach ($tpl->_stack as $element) {
			if ($element[0] == $tpl::SECTION) {
				$in_loop = true;
				break;
			}
		}

		if (!$in_loop) {
			throw new Brindille_Exception(sprintf('Error on line %d: continue can only be used inside a section', $line));
		}

		return '<?php continue; ?>';
	}

	static public function admin_header(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		$tpl->assign('plugins_menu', Plugins::listModulesAndPluginsMenu(Session::getInstance()));
		return $tpl->fetch('_head.tpl');
	}

	static public function admin_footer(array $params): string
	{
		$tpl = Template::getInstance();
		$tpl->assign($params);
		return $tpl->fetch('_foot.tpl');
	}

	static public function password_input(): string
	{
		$tpl = Template::getInstance();
		return $tpl->fetch('users/_password_form.tpl');
	}

	static public function save(array $params, Brindille $tpl, int $line): void
	{
		$name = strtok($tpl->_tpl_path, '/');

		if (!$name) {
			throw new Brindille_Exception('Module name could not be found');
		}

		$table = 'module_data_' . $name;

		if (!empty($params['key'])) {
			if ($params['key'] == 'uuid') {
				$params['key'] = Utils::uuid();
			}

			$field = 'key';
			$where_value = $params['key'];
		}
		elseif (!empty($params['id'])) {
			$field = 'id';
			$where_value = $params['id'];
		}
		else {
			$field = null;
		}

		$key = $params['key'] ?? null;
		$id = $params['id'] ?? null;
		$assign_new_id = $params['assign_new_id'] ?? null;
		$validate = $params['validate_schema'] ?? null;
		$validate_only = $params['validate_only'] ?? null;

		unset($params['key'], $params['id'], $params['assign_new_id'], $params['validate_schema'], $params['validate_only']);

		$db = DB::getInstance();

		if ($key == 'config') {
			$result = $db->firstColumn(sprintf('SELECT config FROM %s WHERE name = ?;', Module::TABLE), $name);
		}
		else {
			$db->exec(sprintf('
				CREATE TABLE IF NOT EXISTS %s (
					id INTEGER NOT NULL PRIMARY KEY,
					key TEXT NULL,
					document TEXT NOT NULL
				);
				CREATE UNIQUE INDEX IF NOT EXISTS %1$s_key ON %1$s (key);', $table));

			if ($field) {
				$result = $db->firstColumn(sprintf('SELECT document FROM %s WHERE %s;', $table, ($field . ' = ?')), $where_value);
			}
			else {
				$result = null;
			}
		}

		// Merge before update
		if ($result) {
			$result = json_decode((string) $result, true);
			$params = array_merge($result, $params);
		}

		if ($validate) {
			$schema = self::read(['file' => $validate], $tpl, $line);

			if ($validate_only && is_string($validate_only)) {
				$validate_only = explode(',', $validate_only);
				$validate_only = array_map('trim', $validate_only);
			}
			else {
				$validate_only = null;
			}

			try {
				$s = JSONSchema::fromString($schema);

				if ($validate_only) {
					$s->validateOnly($params, $validate_only);
				}
				else {
					$s->validate($params);
				}
			}
			catch (\RuntimeException $e) {
				throw new Brindille_Exception(sprintf("ligne %d: impossible de valider le schéma:\n%s\n\n%s",
					$line, $e->getMessage(), json_encode($params, JSON_PRETTY_PRINT)));
			}
		}

		$value = json_encode($params);

		if ($key == 'config') {
			$db->update(Module::TABLE, ['config' => $value], 'name = :name', compact('name'));
			return;
		}

		$document = $value;
		if (!$result) {
			$db->insert($table, compact('document', 'key'));

			if ($assign_new_id) {
				$tpl->assign($assign_new_id, $db->lastInsertId());
			}
		}
		else {
			$db->update($table, compact('document'), sprintf('%s = :match', $field), ['match' => $where_value]);
		}
	}

	static public function delete(array $params, Brindille $tpl, int $line): void
	{
		$name = strtok($tpl->_tpl_path, '/');

		if (!$name) {
			throw new Brindille_Exception('Module name could not be found');
		}

		$table = 'module_data_' . $name;

		if (!empty($params['key'])) {
			$field = 'key';
			$where_value = $params['key'];
		}
		elseif (!empty($params['id'])) {
			$field = 'id';
			$where_value = (int) $params['id'];
		}
		else {
			throw new Brindille_Exception('No "id" or "key" parameter was passed');
		}

		$db = DB::getInstance();
		$db->delete($table, sprintf('%s = ?', $field), $where_value);
	}

	static public function captcha(array $params, Brindille $tpl, int $line)
	{
		$secret = md5(SECRET_KEY . Utils::getSelfURL(false));

		if (isset($params['html'])) {
			$c = Security::createCaptcha($secret, $params['lang'] ?? 'fr');
			return sprintf('<label for="f_c_42">Merci d\'écrire <strong><q>%s</q></strong> en chiffres&nbsp;:</label>
				<input type="text" name="f_c_42" id="f_c_42" placeholder="Exemple : 1234" />
				<input type="hidden" name="f_c_43" value="%s" />',
				$c['spellout'], $c['hash']);
		}
		elseif (isset($params['assign_hash']) && isset($params['assign_number'])) {
			$c = Security::createCaptcha($secret, $params['lang'] ?? 'fr');
			$tpl->assign($params['assign_hash'], $c['hash']);
			$tpl->assign($params['assign_number'], $c['spellout']);
		}
		elseif (isset($params['verify'])) {
			$hash = $_POST['f_c_43'] ?? '';
			$number = $_POST['f_c_42'] ?? '';
		}
		elseif (array_key_exists('verify_number', $params)) {
			$hash = $params['verify_hash'] ?? '';
			$number = $params['verify_number'] ?? '';
		}
		else {
			throw new Brindille_Exception(sprintf('Line %d: no valid arguments supplied for "captcha" function', $line));
		}

		$error = 'Réponse invalide à la vérification anti-robot';

		if (!Security::checkCaptcha($secret, trim($hash), trim($number))) {
			if (isset($params['assign_error'])) {
				$tpl->assign($params['assign_error'], $error);
			}
			else {
				throw new UserException($error);
			}
		}
	}

	static public function mail(array $params, Brindille $tpl, int $line)
	{
		if (empty($params['to'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "to" manquant pour la fonction "mail"', $line));
		}

		if (empty($params['subject'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "subject" manquant pour la fonction "mail"', $line));
		}

		if (empty($params['body'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "body" manquant pour la fonction "mail"', $line));
		}

		if (!empty($params['block_urls']) && preg_match('!https?://!', $params['subject'] . $params['body'])) {
			throw new UserException('Merci de ne pas inclure d\'adresse web (http:…) dans le message');
		}

		static $external = 0;
		static $internal = 0;

		if (is_string($params['to'])) {
			$params['to'] = [$params['to']];
		}

		if (!count($params['to'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line));
		}

		foreach ($params['to'] as &$to) {
			$to = trim($to);
			Email::validateAddress($to);
		}

		unset($to);

		$db = DB::getInstance();
		$email_field = DynamicFields::getFirstEmailField();
		$internal_count = $db->count('users', $db->where($email_field, 'IN', $params['to']));
		$external_count = count($params['to']) - $internal_count;

		if (($external_count + $external) > 1) {
			throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe est limité à un envoi par page', $line));
		}

		if (($internal_count + $internal) > 10) {
			throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse interne est limité à 10 envois par page', $line));
		}

		if ($external_count && preg_match_all('!(https?://.*?)(?=\s|$)!', $params['subject'] . ' ' . $params['body'], $match, PREG_PATTERN_ORDER)) {
			foreach ($match[1] as $m) {
				if (0 !== strpos($m, WWW_URL) && 0 !== strpos($m, ADMIN_URL)) {
					throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m));
				}
			}
		}

		$context = count($params['to']) == 1 ? Emails::CONTEXT_PRIVATE : Emails::CONTEXT_BULK;
		Emails::queue($context, $params['to'], null, $params['subject'], $params['body']);

		$internal += $internal_count;
		$external_count += $external_count;
	}

	static public function debug(array $params, Brindille $tpl)
	{
		if (!count($params)) {
			$params = $tpl->getAllVariables();
		}

		$dump = htmlspecialchars(ErrorManager::dump($params));

		// FIXME: only send back HTML when content-type is text/html, or send raw text
		$out = sprintf('<pre style="background: yellow; color: black; padding: 5px; overflow: auto">%s</pre>', $dump);

		if (!empty($params['stop'])) {
			echo $out; exit;
		}

		return $out;
	}

	static public function error(array $params, Brindille $tpl)
	{
		throw new UserException($params['message'] ?? 'Erreur du module');
	}

	static protected function getFilePath(array $params, string $arg_name, UserTemplate $ut, int $line)
	{
		if (empty($params[$arg_name])) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" manquant', $arg_name, $line));
		}

		if (strpos($params[$arg_name], '..') !== false) {
			throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" invalide', $line, $arg_name));
		}

		$path = $params[$arg_name];

		if (substr($path, 0, 2) == './') {
			$path = Utils::dirname($ut->_tpl_path) . substr($path, 1);
		}
		elseif (substr($path, 0, 1) != '/') {
			$path = Utils::dirname($ut->_tpl_path) . '/' . $path;
		}

		$parts = explode('/', $path);
		$out = [];

		foreach ($parts as $part) {
			if ($part == '..') {
				array_pop($out);
			}
			else {
				$out[] = $part;
			}
		}

		$out = implode('/', $out);

		return $out;
	}

	static public function read(array $params, UserTemplate $ut, int $line): string
	{
		$path = self::getFilePath($params, 'file', $ut, $line);

		$file = Files::get(File::CONTEXT_MODULES . '/' . $path);

		if ($file) {
			$content = $file->fetch();
		}
		else {
			$content = file_get_contents(ROOT . '/modules/' . $path);
		}

		if (!empty($params['base64'])) {
			return base64_encode($content);
		}

		return $content;
	}

	static public function signature(): string
	{
		$file = Config::getInstance()->file('signature');

		if (!$file) {
			return '';
		}

		// We can't just use the image URL as it would not be accessible by PDF programs
		$url = 'data:image/png;base64,' . base64_encode($file->fetch());

		return sprintf('<figure class="signature"><img src="%s" alt="Signature" /></figure>', $url);
	}

	static public function include(array $params, UserTemplate $ut, int $line): void
	{
		$path = self::getFilePath($params, 'file', $ut, $line);

		// Avoid recursive loops
		$from = $ut->get('included_from') ?? [];

		if (in_array($path, $from)) {
			throw new Brindille_Exception(sprintf('Ligne %d : boucle infinie d\'inclusion détectée : %s', $line, $path));
		}

		try {
			$include = new UserTemplate($path);
		}
		catch (\InvalidArgumentException $e) {
			throw new Brindille_Exception(sprintf('Ligne %d : fonction "include" : le fichier à inclure "%s" n\'existe pas', $line, $path));
		}

		$params['included_from'] = array_merge($from, [$path]);

		$include->assignArray(array_merge($ut->getAllVariables(), $params));

		if (!empty($params['capture']) && preg_match('/^[a-z0-9_]+$/', $params['capture'])) {
			$ut::__assign([$params['capture'] => $include->fetch()], $ut, $line);
		}
		else {
			$include->display();
		}

		if (isset($params['keep'])) {
			$keep = explode(',', $params['keep']);
			$keep = array_map('trim', $keep);

			foreach ($keep as $name) {
				// Transmit variables
				$ut::__assign(['var' => $name, 'value' => $include->get($name)], $ut, $line);
			}
		}

		// Transmit nocache to parent template
		if ($include->get('nocache')) {
			$ut::__assign(['nocache' => true], $ut, $line);
		}
	}

	static public function http(array $params, UserTemplate $tpl): void
	{
		if (headers_sent()) {
			return;
		}

		if (isset($params['redirect'])) {
			Utils::redirectDialog($params['redirect']);
		}

		if (isset($params['code'])) {
			static $codes = [
				100 => 'Continue',
				101 => 'Switching Protocols',
				102 => 'Processing',
				200 => 'OK',
				201 => 'Created',
				202 => 'Accepted',
				203 => 'Non-Authoritative Information',
				204 => 'No Content',
				205 => 'Reset Content',
				206 => 'Partial Content',
				207 => 'Multi-Status',
				300 => 'Multiple Choices',
				301 => 'Moved Permanently',
				302 => 'Found',
				303 => 'See Other',
				304 => 'Not Modified',
				305 => 'Use Proxy',
				306 => 'Switch Proxy',
				307 => 'Temporary Redirect',
				400 => 'Bad Request',
				401 => 'Unauthorized',
				402 => 'Payment Required',
				403 => 'Forbidden',
				404 => 'Not Found',
				405 => 'Method Not Allowed',
				406 => 'Not Acceptable',
				407 => 'Proxy Authentication Required',
				408 => 'Request Timeout',
				409 => 'Conflict',
				410 => 'Gone',
				411 => 'Length Required',
				412 => 'Precondition Failed',
				413 => 'Request Entity Too Large',
				414 => 'Request-URI Too Long',
				415 => 'Unsupported Media Type',
				416 => 'Requested Range Not Satisfiable',
				417 => 'Expectation Failed',
				418 => 'I\'m a teapot',
				422 => 'Unprocessable Entity',
				423 => 'Locked',
				424 => 'Failed Dependency',
				425 => 'Unordered Collection',
				426 => 'Upgrade Required',
				449 => 'Retry With',
				450 => 'Blocked by Windows Parental Controls',
				500 => 'Internal Server Error',
				501 => 'Not Implemented',
				502 => 'Bad Gateway',
				503 => 'Service Unavailable',
				504 => 'Gateway Timeout',
				505 => 'HTTP Version Not Supported',
				506 => 'Variant Also Negotiates',
				507 => 'Insufficient Storage',
				509 => 'Bandwidth Limit Exceeded',
				510 => 'Not Extended',
			];

			if (!isset($codes[$params['code']])) {
				throw new Brindille_Exception('Code HTTP inconnu');
			}

			header(sprintf('HTTP/1.1 %d %s', $params['code'], $codes[$params['code']]), true);
		}

		if (!empty($params['type'])) {
			if ($params['type'] == 'pdf') {
				$params['type'] = 'application/pdf';
			}

			header('Content-Type: ' . $params['type'], true);
		}

		if (isset($params['download'])) {
			header(sprintf('Content-Disposition: attachment; filename="%s"', Utils::safeFileName($params['download'])), true);
		}
		elseif (isset($params['inline'])) {
			header(sprintf('Content-Disposition: inline; filename="%s"', Utils::safeFileName($params['inline'])), true);
		}
	}

	static public function button(array $params): string
	{
		static $forms = [];

		$hash = md5(Utils::getSelfURI(false));

		// Always add CSRF protection when a submit button is present in the form
		if (isset($params['type']) && $params['type'] == 'submit' && !in_array($hash, $forms)) {
			$params['csrf_key'] = 'form_' . $hash;
		}

		return CommonFunctions::button($params);
	}

	static public function form_errors(array $params, UserTemplate $tpl): string
	{
		if (($e = $tpl->get('form_errors')) && is_array($e)) {
			return sprintf('<p class="error block">%s</p>', nl2br(htmlspecialchars(implode("\n", $e))));
		}

		return '';
	}

	static public function redirect(array $params): void
	{
		Utils::redirectDialog($params['to'] ?? null);
	}
}