Artifact 1d576f102a4888bf2176212b528ccd23c15913b0d402a97fa79a019fa1d33134:


<?php

namespace Garradin;

use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Users\User;
use Garradin\Entities\Files\File;
use Garradin\Entities\Search;
use Garradin\Users\DynamicFields;
use Garradin\Users\Session;
use Garradin\Files\Files;
use Garradin\UserTemplate\Modules;
use Garradin\Plugins;

use KD2\HTTP;

/**
 * Pour procéder à l'installation de l'instance Garradin
 * Utile pour automatiser l'installation sans passer par la page d'installation
 */
class Install
{
	/**
	 * List of plugins that should be displayed during installation (if present)
	 */
	const DEFAULT_PLUGINS = [
		'caisse',
		'taima',
	];

	const DEFAULT_MODULES = [
		'recus_fiscaux',
		'carte_membre',
		'recu_don',
		'recu_paiement',
		//'bilan_pc',
		//'invoice',
	];

	/**
	 * This sends the current installed version, as well as the PHP and SQLite versions
	 * for statistics purposes.
	 *
	 * You can disable this by setting DISABLE_INSTALL_PING to TRUE in CONFIG_FILE
	 */
	static public function ping(): void
	{
		if (DISABLE_INSTALL_PING) {
			return;
		}

		$options = '';
		$db = new \SQLite3(':memory:');
		$res = $db->query('PRAGMA compile_options;');

		while ($row = $res->fetchArray(\SQLITE3_NUM)) {
			if (0 !== strpos($row[0], 'ENABLE_')) {
				continue;
			}

			$options .= substr($row[0], strlen('ENABLE_')) . ',';
		}

		(new HTTP)->POST(PING_URL, [
			'id'      => sha1(WWW_URL . SECRET_KEY . ROOT),
			'version' => paheko_version(),
			'sqlite'  => \SQLite3::version()['versionString'],
			'php'     => PHP_VERSION,
			'sqlite_options' => trim($options, ', '),
		]);
	}

	/**
	 * Reset the database to empty and create a new user with the same password
	 */
	static public function reset(Users\Session $session, string $password, array $options = [])
	{
		$config = (object) Config::getInstance()->asArray();
		$user = $session->getUser();

		if (!$session->checkPassword($password, $user->passe))
		{
			throw new UserException('Le mot de passe ne correspond pas.');
		}

		if (!trim($config->org_name)) {
			throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.');
		}

		if (!trim($user->identite)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.');
		}

		if (!trim($user->email)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas d\'adresse e-mail, merci de la renseigner.');
		}

		$name = date('Y-m-d-His-') . 'avant-remise-a-zero';

		Backup::create($name);

		// Keep a backup file of files
		if (FILE_STORAGE_BACKEND == 'FileSystem') {
			$name = 'documents_' . $name . '.zip';
			Files::zipAll(CACHE_ROOT . '/' . $name);
			Files::callStorage('truncate');
			@mkdir(FILE_STORAGE_CONFIG . '/documents');
			@rename(CACHE_ROOT . '/' . $name, FILE_STORAGE_CONFIG . '/documents/' . $name);
		}

		Config::deleteInstance();
		DB::getInstance()->close();
		DB::deleteInstance();

		file_put_contents(CACHE_ROOT . '/reset', json_encode([
			'password'     => $session::hashPassword($password),
			'name'         => $user->name(),
			'email'        => $user->email,
			'organization' => $config->org_name,
			'country'      => $config->country,
		]));

		rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero'));

		self::showProgressSpinner('!install.php', 'Remise à zéro en cours…');
		exit;
	}

	/**
	 * Continues reset after page reload
	 */
	static public function checkReset()
	{
		if (!file_exists(CACHE_ROOT . '/reset')) {
			return;
		}

		$data = json_decode(file_get_contents(CACHE_ROOT . '/reset'));

		if (!$data) {
			throw new \LogicException('Invalid reset data');
		}

		try {
			// We can't use the real password, as it might not be valid (too short or compromised)
			$ok = self::install($data->country ?? 'FR', $data->organization ?? 'Association', $data->name, $data->email, md5($data->password));

			// Restore password
			DB::getInstance()->preparedQuery('UPDATE membres SET passe = ? WHERE id = 1;', [$data->password]);

			if (defined('\Garradin\LOCAL_LOGIN') && \Garradin\LOCAL_LOGIN) {
				Session::getInstance()->refresh();
			}
		}
		catch (\Exception $e) {
			Config::deleteInstance();
			DB::getInstance()->close();
			DB::deleteInstance();
			Utils::safe_unlink(DB_FILE);
			throw $e;
		}

		@unlink(CACHE_ROOT . '/reset');

		Utils::redirect('!config/advanced/?msg=RESET');
	}

	static protected function assert(bool $assertion, string $message)
	{
		if (!$assertion) {
			throw new ValidationException($message);
		}
	}

	static public function installFromForm(array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		self::assert(isset($source['name']) && trim($source['name']) !== '', 'Le nom de l\'association n\'est pas renseigné');
		self::assert(isset($source['user_name']) && trim($source['user_name']) !== '', 'Le nom du membre n\'est pas renseigné');
		self::assert(isset($source['user_email']) && trim($source['user_email']) !== '', 'L\'adresse email du membre n\'est pas renseignée');
		self::assert(isset($source['password']) && isset($source['password_confirmed']) && trim($source['password']) !== '', 'Le mot de passe n\'est pas renseigné');

		self::assert((bool)filter_var($source['user_email'], FILTER_VALIDATE_EMAIL), 'Adresse email invalide');

		self::assert(strlen($source['password']) >= User::MINIMUM_PASSWORD_LENGTH, 'Le mot de passe est trop court');
		self::assert($source['password'] === $source['password_confirmed'], 'La vérification du mot de passe ne correspond pas');

		$plugins = isset($source['plugins']) ? array_keys($source['plugins']) : [];
		$modules = isset($source['modules']) ? array_keys($source['modules']) : [];

		try {
			self::install($source['country'], $source['name'], $source['user_name'], $source['user_email'], $source['password'], $plugins, $modules);
			self::ping();
		}
		catch (\Exception $e) {
			@unlink(DB_FILE);
			throw $e;
		}
	}

	static public function install(string $country_code, string $name, string $user_name, string $user_email, string $user_password, array $plugins = [], array $modules = []): void
	{
		if (file_exists(DB_FILE)) {
			throw new UserException('La base de données existe déjà.');
		}

		self::checkAndCreateDirectories();
		Files::disableQuota();
		$db = DB::getInstance();

		$db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert');

		// Création de la base de données
		$db->begin();
		$db->exec('PRAGMA application_id = ' . DB::APPID . ';');
		$db->setVersion(paheko_version());
		$db->exec(file_get_contents(DB_SCHEMA));
		$db->commit();

		file_put_contents(SHARED_CACHE_ROOT . '/version', paheko_version());

		$currency = $country_code == 'CH' ? 'CHF' : '€';

		// Configuration de base
		$config = Config::getInstance();
		$config->setCreateFlag();
		$config->import([
			'org_name'      => $name,
			'org_email'     => $user_email,
			'currency'      => $currency,
			'country'       => $country_code,
			'site_disabled' => true,
			'log_retention' => 365,
			'analytical_set_all' => true,
		]);

		$fields = DynamicFields::getInstance();
		$fields->install();

		// Create default category for common users
		$cat = new Category;
		$cat->setAllPermissions(Session::ACCESS_NONE);
		$cat->importForm([
			'name' => 'Membres actifs',
			'perm_connect' => Session::ACCESS_READ,
		]);
		$cat->save();

		$config->set('default_category', $cat->id());

		// Create default category for ancient users
		$cat = new Category;
		$cat->importForm([
			'name' => 'Anciens membres',
			'hidden' => 1,
		]);
		$cat->setAllPermissions(Session::ACCESS_NONE);
		$cat->save();

		// Create default category for admins
		$cat = new Category;
		$cat->importForm([
			'name' => 'Administrateurs',
		]);
		$cat->setAllPermissions(Session::ACCESS_ADMIN);
		$cat->save();

		// Create first user
		$user = new User;
		$user->set('id_category', $cat->id());
		$user->importForm([
			'numero'      => 1,
			'nom'         => $user_name,
			'email'       => $user_email,
			'pays'        => 'FR',
		]);

		$user->importSecurityForm(false, [
			'password' => $user_password,
			'password_confirmed' => $user_password,
		]);

		$user->save();

		$config->set('files', array_map(fn () => null, $config::FILES));

		$welcome_text = sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nSi vous êtes perdu, n'hésitez pas à consulter l'aide :-)", $name);

		$config->setFile('admin_homepage', $welcome_text);

        // Import accounting chart
        $chart = Charts::installCountryDefault($country_code);

		// Create an example saved search (users)
		$query = (object) [
			'groups' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'lettre_infos',
						'operator' => '= 1',
						'values'   => [],
					],
				],
			]],
			'order' => 'numero',
			'desc' => true,
			'limit' => '10000',
		];

		$search = new Search;
		$search->import([
			'label'   => 'Inscrits à la lettre d\'information',
			'target'  => $search::TARGET_USERS,
			'type'    => $search::TYPE_JSON,
			'content' => json_encode($query),
		]);
		$search->created = new \DateTime;
		$search->save();

		// Create an example saved search (accounting)
		$query = (object) [
			'groups' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'p.code',
						'operator' => 'IS NULL',
						'values'   => [],
					],
				],
			]],
			'order' => 't.id',
			'desc' => false,
			'limit' => '100',
		];


		$search = new Search;
		$search->import([
			'label'   => 'Écritures sans projet',
			'target'  => $search::TARGET_ACCOUNTING,
			'type'    => $search::TYPE_JSON,
			'content' => json_encode($query),
		]);
		$search->created = new \DateTime;
		$search->save();

		$config->save();

		// Install welcome plugin if available
		$has_welcome_plugin = Plugins::exists('welcome');

		if ($has_welcome_plugin) {
			Plugins::install('welcome');
		}

		foreach ($plugins as $plugin) {
			Plugins::install($plugin);
		}

		Modules::refresh();

		foreach ($modules as $module) {
			$m = Modules::get($module);
			$m->set('enabled', true);
			$m->save();
		}

		Files::enableQuota();
	}

	static public function checkAndCreateDirectories()
	{
		// Vérifier que les répertoires vides existent, sinon les créer
		$paths = [
			DATA_ROOT,
			PLUGINS_ROOT,
			CACHE_ROOT,
			SHARED_CACHE_ROOT,
			USER_TEMPLATES_CACHE_ROOT,
			STATIC_CACHE_ROOT,
			SMARTYER_CACHE_ROOT,
			SHARED_USER_TEMPLATES_CACHE_ROOT,
		];

		foreach ($paths as $path)
		{
			$index_file = $path . '/index.html';
			Utils::safe_mkdir($path, 0777, true);

			if (!is_dir($path))
			{
				throw new \RuntimeException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.');
			}

			// On en profite pour vérifier qu'on peut y lire et écrire
			if (!is_writable($path) || !is_readable($path))
			{
				throw new \RuntimeException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.');
			}
			if (file_exists($index_file) AND (!is_writable($index_file) || !is_readable($index_file))) {
				throw new \RuntimeException('Le fichier ' . $index_file . ' n\'est pas accessible en lecture/écriture.');
			}

			// Some basic safety against misconfigured hosts
			file_put_contents($index_file, '<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL was not found on this server.</p></body></html>');
		}

		return true;
	}

	static public function setLocalConfig(string $key, $value, bool $overwrite = true): void
	{
		$path = ROOT . DIRECTORY_SEPARATOR . CONFIG_FILE;
		$new_line = sprintf('const %s = %s;', $key, var_export($value, true));

		if (@filesize($path)) {
			$config = file_get_contents($path);

			$pattern = sprintf('/^.*(?:const\s+%s|define\s*\(.*%1$s).*$/m', $key);

			$config = preg_replace($pattern, $new_line, $config, -1, $count);

			if ($count && !$overwrite) {
				return;
			}

			if (!$count)
			{
				$config = preg_replace('/\?>.*/s', '', $config);
				$config .= PHP_EOL . $new_line . PHP_EOL;
			}
		}
		else
		{
			$config = '<?php' . PHP_EOL
				. 'namespace Garradin;' . PHP_EOL . PHP_EOL
				. $new_line . PHP_EOL;
		}

		file_put_contents($path, $config);
	}

	static public function showProgressSpinner(?string $next = null, string $message = '')
	{
		$next = $next ? sprintf('<meta http-equiv="refresh" content="0;url=%s" />', Utils::getLocalURL($next)) : '';

		printf('<!DOCTYPE html>
		<html>
		<head>
		<meta charset="utf-8" />
		<style type="text/css">
		* { padding: 0; margin: 0; }
		html {
			height: 100%%;
		}
		body {
			font-family: sans-serif;
			text-align: center;
			display: flex;
			align-items: center;
			justify-content: center;
			height: 100%%;
		}
		h2, p {
			margin-bottom: 1rem;
		}
		div {
			position: relative;
			max-width: 500px;
			padding: 1em;
			border-radius: .5em;
			background: #ccc;
		}
		.spinner h2::after {
			display: block;
			content: " ";
			margin: 1rem auto;
			width: 50px;
			height: 50px;
			border: 5px solid #999;
			border-radius: 50%%;
			border-top-color: #000;
			animation: spin 1s ease-in-out infinite;
		}

		@keyframes spin { to { transform: rotate(360deg); } }
		</style>
		%s
		</head>
		<body>
		<div class="spinner">
			<h2>%s</h2>
		</div>', $next, nl2br(htmlspecialchars($message)));

		flush();
	}
}