<?php
declare(strict_types = 1);

/**
 * Méthodes spécifiques à l'application.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class App
{
	/**
	 * Regexp pour le nom de répertoire des thèmes.
	 *
	 * @var string
	 */
	CONST TPL_REGEXP = '`^[a-z0-9_-]{1,30}$`i';



	/**
	 * Nom du script courant ('admin' ou 'gallery').
	 *
	 * @var string
	 */
	public static $scriptName = 'gallery';

	/**
	 * Chemin d'accès à la page courante.
	 *
	 * @var string
	 */
	public static $scriptPath = '/';



	/**
	 * Vérifie si un nom d'utilisateur, un mot ou une adresse IP
	 * se trouve dans une des listes noires correspondantes.
	 *
	 * @param string $form
	 * @param string $name
	 * @param string $email
	 * @param string $message
	 * @param array $post
	 *
	 * @return array|bool
	 */
	public static function blacklists(string $form = '',
	string $name = '', string $email = '', string $message = '', array $post = [])
	{
		// Vérification de l'adresse IP.
		if ($r = Utility::listSearch((string) $_SERVER['REMOTE_ADDR'],
		Config::$params['blacklist_ips']))
		{
			$rejected =
			[
				'entry' => $r['entry'],
				'list' => 'ips',
				'text' => __('Votre adresse IP est bannie.')
			];
		}

		// Vérification du nom d'utilisateur.
		else if ($name && $r = Utility::listSearch($name, Config::$params['blacklist_names']))
		{
			$rejected =
			[
				'entry' => $r['entry'],
				'list' => 'names',
				'text' => __('Ce nom d\'utilisateur est banni.')
			];
		}

		// Vérification de l'adresse de courriel.
		else if ($email && $r = Utility::listSearch($email, Config::$params['blacklist_emails']))
		{
			$rejected =
			[
				'entry' => $r['entry'],
				'list' => 'emails',
				'text' => __('Cette adresse de courriel est bannie.')
			];
		}

		// Vérification du message.
		else if ($message && $r = Utility::listSearch($message,
		Config::$params['blacklist_words'], TRUE))
		{
			$rejected =
			[
				'entry' => $r['entry'],
				'list' => 'words',
				'text' => sprintf(
					__('Votre message contient un mot non autorisé : "%s".'), $r['match']
				)
			];
		}

		if (isset($rejected))
		{
			// Log d'activité.
			if ($form)
			{
				self::logActivity(
					$form . '_rejected_blacklist_' . $rejected['list'], $rejected['entry'], $post
				);
			}

			return $rejected;
		}

		return TRUE;
	}

	/**
	 * Définit proprement les valeurs des cases à cocher d'un formulaire.
	 *
	 * @param array $arr
	 *
	 * @return void
	 */
	public static function checkboxes(array &$arr): void
	{
		foreach ($arr as $k => &$v)
		{
			if (!is_array($v))
			{
				continue;
			}
			if ($k == '_checkboxes')
			{
				foreach ($v as &$name)
				{
					$arr[$name] = (int) array_key_exists($name, $arr);
				}
			}
			else
			{
				self::checkboxes($v);
			}
		}
	}

	/**
	 * Opérations à effectuer une fois par jour.
	 *
	 * @return void
	 */
	public static function dailyUpdate(): void
	{
		if (Config::$params['daily_update'] == date('Y-m-d'))
		{
			return;
		}

		// Suppression des sessions périmées.
		DB::execute('DELETE
					   FROM {sessions}
					  WHERE session_expire < NOW()');

		// Suppression des utilisateurs en attente dont
		// la date de validation par courriel a expirée.
		DB::execute('DELETE
					   FROM {users}
					  WHERE user_status = "-2"
					    AND user_crtdt < DATE_ADD(NOW(), INTERVAL -2 DAY)');

		// Suppression des recherches périmées.
		DB::execute('DELETE
					   FROM {search}
					  WHERE search_date < DATE_ADD(NOW(), INTERVAL -2 DAY)');

		// Mise à jour de la date de dernière mise à jour.
		DB::execute('UPDATE {config}
						SET conf_value = ?
					  WHERE conf_name = "daily_update"', date('Y-m-d'));

		// Suppression des logs utilisateurs.
		if (Config::$params['users_log_activity_delete'])
		{
			$days = (int) Config::$params['users_log_activity_delete_days'];
			DB::execute("DELETE
						   FROM {users_logs}
					      WHERE log_date < DATE_ADD(NOW(), INTERVAL -$days DAY)");
		}

		// Sauvegarde de la base de données SQLite.
		if (CONF_DB_TYPE == 'sqlite' && CONF_SQLITE_BACKUP)
		{
			$db_backup_dir = GALLERY_ROOT . '/db/backup';
			$db_backup_file = $db_backup_dir . '/sqlite_' . date('Y-m-d') . '.db';
			if (file_exists($db_backup_dir) && file_exists(CONF_DB_NAME)
			&& !file_exists($db_backup_file))
			{
				File::copy(CONF_DB_NAME, $db_backup_file);
			}
			$n = 0;
			foreach (scandir($db_backup_dir, SCANDIR_SORT_DESCENDING) as &$f)
			{
				if (preg_match('`^sqlite_`', $f) && ++$n > CONF_SQLITE_BACKUP_MAX)
				{
					File::unlink($db_backup_dir . '/' . $f);
				}
			}
		}

		// Suppression des répertoires temporaires datant de plus d'une semaine.
		$temp_dir = GALLERY_ROOT . '/cache/temp';
		if (is_dir($temp_dir))
		{
			foreach (scandir($temp_dir) as &$f)
			{
				if (is_dir($temp_dir . '/' . $f) && preg_match('`^[a-z\d]{40}$`i', $f)
				&& filemtime($temp_dir . '/' . $f) < time() - 604800)
				{
					File::rmdir($temp_dir . '/' . $f);
				}
			}
		}
	}

	/**
	 * Création des paramètres GET liés aux filtres.
	 *
	 * @return void
	 */
	public static function filtersGET(): void
	{
		// Filtres sans paramètre.
		$filters =
		[
			'comments',
			'favorites',
			'images',
			'items',
			'pending',
			'recent-images',
			'recent-items',
			'recent-videos',
			'selection',
			'views',
			'videos',
			'votes'
		];

		// Filtres avec paramètre.
		$filters_p =
		[
			'action',
			'camera-brand',
			'camera-model',
			'date',
			'date-created',
			'date-published',
			'datetime',
			'ip',
			'item',
			'note',
			'search',
			'tag',
			'tags',
			'result',
			'user',
			'user-favorites',
			'user-images',
			'user-items',
			'user-videos'
		];

		$regexp = implode('|', array_map('preg_quote', array_merge($filters, $filters_p)));
		$regexp = "`/(?:($regexp)(?:/([^/]+))?(?:/([^/]+))?)$`i";
		if (preg_match($regexp, $_GET['q_pageless'], $m))
		{
			// Filtre sur une catégorie.
			if ($_GET['section'] == 'category')
			{
				$_GET['section_file'] = 'album';
				$_GET['album_id'] = $_GET['category_id'];
				
			}

			// Nom du filtre.
			$_GET['filter'] = $m[1];

			// Type de filtre.
			$_GET['filter_type'] = in_array($_GET['filter'], $filters) ? 'filter' : 'filter_p';

			// Valeur du filtre.
			$_GET['filter_value'] = $m[2] ?? '';

			// Catégorie dans laquelle s'applique le filtre.
			$_GET['filter_cat_id'] = $m[3] ?? $_GET['category_id'] ?? 0;
			if ($_GET['section'] == 'item' && count($m) == 3)
			{
				$_GET['filter_cat_id'] = $m[2];
			}

			// Partie de la requête correspondant au filtre.
			$_GET['q_filter'] = $m[0];

			// Requête sans filtre.
			$_GET['q_filterless'] = preg_replace($regexp, '', $_GET['q_pageless']);
		}
	}

	/**
	 * Convertit une valeur en secondes dans le format HH:MM:SS ou MM:SS.
	 *
	 * @param mixed $seconds
	 *
	 * @return string
	 */
	public static function formatDuration($seconds): string
	{
		$t = round((int) $seconds);

		if ($t > 3599)
		{
			$div = $t / 3600;
			$hour = floor($div);
			$min = ($div - $hour) * 60;
			$sec = ($min - floor($min)) * 60;
			return sprintf('%02d:%02d:%02d', $hour, floor($min), round($sec));
		}
		else
		{
			$div = $t / 60;
			$min = floor($div);
			$sec = ($div - $min) * 60;
			return sprintf('%02d:%02d', $min, round($sec));
		}
	}

	/**
	 * Retourne l'URL d'un fichier.
	 *
	 * @param string $path
	 *   Chemin d'accès au fichier.
	 * @param bool $path
	 *   Télécharger le fichier ?
	 *
	 * @return string
	 */
	public static function getFileSource(string $path, bool $download = FALSE): string
	{
		$key = Security::fileKeyHash([$path]);
		$file = $download ? 'download' : 'file';

		return CONF_GALLERY_PATH . "/$file.php?key=$key&file=$path";
	}

	/**
	 * Génère le chemin d'accès à l'application.
	 *
	 * @return string
	 */
	public static function getGalleryPath(): string
	{
		$p = preg_replace('`^[a-z\d]+://[^/]+/`i', '/', $_SERVER['SCRIPT_NAME']);
		$p = dirname(dirname($p));
		$p = str_replace(' ', '%20', $p);
		$p = preg_match('`^[./]*$`', $p) ? '' : $p;
		$p = preg_replace('`(?:[\x5c]|/+$)`', '', $p);

		return $p;
	}

	/**
	 * Retourne le chemin d'un fichier selon le type d'image redimensionnée demandé.
	 *
	 * @param string $type
	 *   Type de vignette.
	 * @param string $path
	 *   Chemin d'accès de l'élément.
	 * @param string $id
	 *   Identifiant de l'élément.
	 * @param string $adddt
	 *   Date d'ajout de l'élément.
	 * @param string $size
	 *   Dimensions max. de l'image.
	 * @param int $orientation
	 *   Orientation de l'image (valeur du paramètre EXIF).
	 * @param string $quality
	 *   Qualité GD de l'image.
	 * @param string $crop
	 *   Paramètres de rognage (uniquement pour vignettes).
	 *
	 * @return string
	 */
	public static function getImageFilepath(string $type, string $path, string $id,
	string $adddt, string $size, int $orientation, string $quality,
	string $crop = ''): string
	{
		if (!preg_match('`^\d{2,4}(x\d{2,4})?$`', $size) || !preg_match('`^\d{2,3}$`', $quality))
		{
			return '';
		}

		switch ($type)
		{
			case 'cat' :
			case 'item' :
			case 'pending' :
				$dir = 'thumbs';
				$config_type = 'thumbs_type';
				break;

			case 'resize' :
				$dir = 'resize';
				$config_type = 'items_resize_type';
				break;

			case 'users_resize' :
				$dir = 'resize';
				$config_type = 'users_items_resize_type';
				break;

			default :
				return '';
		}

		$ext = strtolower(preg_replace('`^.+(\.[a-z0-9]{2,4})$`i', '$1', $path));
		$ext = ($ext == '.mp4' || $ext == '.webm') ? '.jpg' : $ext;

		switch (strtolower(Config::$params[$config_type]))
		{
			case 'avif' :
				$ext = '.avif';
				break;

			case 'jpeg' :
				$ext = '.jpg';
				break;

			case 'webp' :
				$ext = '.webp';
				break;
		}

		$params = $orientation > 1 && $orientation < 9
			? [$size, $quality, $adddt, $crop, $orientation]
			: [$size, $quality, $adddt, $crop];

		return 'cache/' . $dir . '/' . $id . '-' . self::hashFilename($id, $params) . $ext;
	}

	/**
	 * Retourne l'URL d'une image redimensionnée.
	 *
	 * @param array $i
	 *   Informations de l'image.
	 *
	 * @return string
	 */
	public static function getImageResize(array $i): string
	{
		// Doit-on redimensionner ?
		$users = (Config::$params['users']
			&& !Auth::$groupPerms['image_original']
			&& Config::$params['users_items_resize'])
			? 'users_' : '';
		if (!(Item::isImage($i['item_type']) && (Config::$params['items_resize'] || $users)))
		{
			return self::getFileSource($i['item_path']);
		}

		// Paramètres.
		$item = $i['item_path'];
		$quality = (int) Config::$params[$users . 'items_resize_quality'];
		$height = (int) Config::$params[$users . 'items_resize_height'];
		$width = (int) Config::$params[$users . 'items_resize_width'];
		$size = $width . 'x' . $height;
		$orientation = (int) $i['item_orientation'];

		// Si l'image n'est pas plus grande que les dimensions maximum,
		// ou si les dimensions maximum sont égales à 0
		// on retourne l'URL du fichier original.
		if (($i['item_width'] <= $width && $i['item_height'] <= $height)
		|| ($width < 1 && $height < 1))
		{
			return self::getFileSource($i['item_path']);
		}

		// Nom de fichier de l'image redimensionnée.
		$file = self::getImageFilepath($users . 'resize', $item, (string) $i['item_id'],
			$i['item_adddt'], $size, $orientation, (string) $quality);
		$file = basename($file);

		// Clé de sécurité.
		$params = $orientation > 1 && $orientation < 9
			? [$file, $item, $quality, $size, $orientation]
			: [$file, $item, $quality, $size];
		$key = Security::fileKeyHash($params);

		return CONF_GALLERY_PATH
			. "/resize.php?file=$file&item=$item&key=$key&quality=$quality&size=$size"
			. ($orientation > 1 && $orientation < 9 ? "&orientation=$orientation" : "");
	}

	/**
	 * Retourne la description d'une catégorie ou d'un fichier
	 * à destination de la balise <meta name="description">.
	 *
	 * @param string $desc
	 *
	 * @return string
	 */
	public static function getMetaDescription(string $desc): string
	{
		return Utility::trimAll(preg_replace('`[\n\r]+`', ' ', strip_tags($desc)));
	}

	/**
	 * Retourne le chemin du répertoire système
	 * utilisé pour les fichiers temporaires.
	 *
	 * @return mixed
	 *   Retourne le chemin absolu du répertoire temporaire,
	 *   ou FALSE en cas d'erreur.
	 */
	public static function getTempDirSys()
	{
		if (function_exists('sys_get_temp_dir'))
		{
			return realpath(sys_get_temp_dir());
		}
		return FALSE;
	}

	/**
	 * Retourne l'emplacement d'une vignette.
	 *
	 * @param string $type
	 *   Type de vignette ('cat', 'item', 'pending').
	 * @param array $i
	 *   Informations utiles de l'élément.
	 * @param string $size
	 *   Dimensions de la vignette.
	 * @param string $quality
	 *   Qualité de l'image.
	 * @param bool $poster
	 *   Indique s'il s'agit d'une vignette de vidéo.
	 *
	 * @return string
	 */
	public static function getThumbSource(string $type,
	array $i, string $size = '', string $quality = '', bool $poster = FALSE): string
	{
		// Paramètres.
		if (!preg_match('`^\d{2,4}(x\d{2,4})?$`', $size))
		{
			$size = (string) ($type == 'cat' ? CONF_THUMBS_SIZE_CAT : CONF_THUMBS_SIZE_ITEM);
		}

		if ((int) $quality < 1 || (int) $quality > 100)
		{
			$quality = (string) (int) Config::$params['thumbs_quality'];
		}

		$adddt = (string) $i['item_adddt'];
		$id = (string) $i['item_id'];
		$id = ($type == 'pending') ? "p$id" : $id;
		$item = (string) $i['item_path'];
		$orientation = (int) $i['item_orientation'];

		// Paramètres de rognage (uniquement pour la galerie).
		$crop = '';
		if (self::$scriptName == 'gallery' && $type != 'pending' && !$poster)
		{
			$crop = $type == 'cat' ? ($i['cat_tb_params'] ?? '') : ($i['item_tb_params'] ?? '');
			$test = str_replace(['{', '}', '"', 'x', 'y', 'w', 'h', ':'], '', $crop);
			$crop = preg_match('`^[\d,]{7,32}$`', $test) ? $test : '';
		}

		// Image externe.
		if (array_key_exists('thumb_id', $i) && $i['thumb_id'] == 0)
		{
			$item = $i['cat_id'] . '.jpg';
			$id = 'e' . $i['cat_id'];
		}

		// Chemin de la vignette.
		$path = self::getImageFilepath($type, $item, $id,
			$adddt, $size, $orientation, $quality, $crop);

		// Vidéos.
		$no_thumb = '';
		if (Item::isVideo($i['item_type']))
		{
			$hash = Video::uniqueHash($id, $adddt);
			$capture_file = Video::getCapture((int) $i['item_id'], $adddt, $type == 'pending');
			$capture_file = GALLERY_ROOT . '/cache/captures/' . $capture_file;
			$item = $id . '-' . $hash . '.vid';
			if (!file_exists($capture_file))
			{
				$no_thumb = CONF_GALLERY_PATH . '/images/video-no-thumb.png#';
			}
		}

		// Si la vignette n'est pas sécurisée et qu'elle existe déjà,
		// on peut l'afficher directement.
		if (!CONF_THUMBS_SECURE && file_exists(GALLERY_ROOT . '/' . $path))
		{
			return CONF_GALLERY_PATH . '/' . $path;
		}

		$file = basename($path);
		$params = $orientation > 1 && $orientation < 9
			? [$file, $item, $quality, $size, $type, $crop, $orientation]
			: [$file, $item, $quality, $size, $type, $crop];
		$key = Security::fileKeyHash($params);

		return $no_thumb . CONF_GALLERY_PATH
			. "/thumb.php?file=$file&item=$item&key=$key&quality=$quality&size=$size"
			. ($orientation > 1 && $orientation < 9 ? "&orientation=$orientation" : "")
			. "&type=$type" . ($crop ? "&crop=$crop" : "");
	}

	/**
	 * Retourne le chemin d'accès à une page de l'application
	 * depuis la racine du site.
	 *
	 * @param string $page
	 * @param bool $p_only
	 *
	 * @return string
	 */
	public static function getURL(string $page = '', bool $p_only = FALSE): string
	{
		// Encodage.
		$page = str_replace(['%23', '%2C', '%2F'], ['#', ',', '/'], rawurlencode($page));

		// Valeur du paramètre d'URL seulement.
		if ($p_only)
		{
			return $page;
		}

		// Si URL rewriting ou aucune page, on ne met pas "?q=".
		$q = ((CONF_URL_REWRITE && self::$scriptPath == '/') || Utility::isEmpty($page))
			? ''
			: '?q=';

		return CONF_GALLERY_PATH . self::$scriptPath . $q . $page;
	}

	/**
	 * Génère un lien vers l'admin depuis la galerie.
	 *
	 * @param string $url
	 *
	 * @return string
	 */
	public static function getURLAdmin(string $url = ''): string
	{
		$script_path = self::$scriptPath;
		self::$scriptPath = '/' . CONF_ADMIN_DIR . '/';
		$url = self::getURL($url);
		self::$scriptPath = $script_path;

		return $url;
	}

	/**
	 * Génère un lien vers la galerie depuis l'admin.
	 *
	 * @param string $url
	 *
	 * @return string
	 */
	public static function getURLGallery(string $url = ''): string
	{
		$script_path = self::$scriptPath;
		self::$scriptPath = '/';
		$url = self::getURL($url);
		self::$scriptPath = $script_path;

		return $url;
	}

	/**
	 * Retourne le nom d'URL d'un objet.
	 *
	 * @param string $name
	 *   Nom de l'objet.
	 *
	 * @return string
	 */
	public static function getURLName(string $name): string
	{
		$name = str_replace('/', '-', $name);
		#$name = Utility::removeAccents($name, TRUE);
		$name = (string) preg_replace('`[\x{FE00}-\x{FE0F}]`u', '', $name);
		$name = (string) preg_replace('`[^-\d\w_]`ui', ' ', $name);
		$name = Utility::trimAll($name);
		$name = (string) preg_replace('`[-\s]+`', '-', $name);
		#$name = mb_strtolower($name);

		if (substr($name, -1) == '-')
		{
			$name = substr($name, 0, -1);
		}
		if (substr($name, 0, 1) == '-')
		{
			$name = substr($name, 1);
		}
		if ($name === '')
		{
			$name = '-';
		}

		return $name;
	}

	/**
	 * Retourne un nom de répertoire valide à partir de celui fourni.
	 *
	 * @param string $dirname
	 *   Nom de répertoire.
	 *
	 * @return string
	 */
	public static function getValidDirname(string $dirname): string
	{
		$dirname = Utility::UTF8($dirname);
		$dirname = Utility::removeAccents($dirname, TRUE);
		$dirname = preg_replace('`([^-_a-z0-9])`ui', '_', $dirname);

		if (strlen($dirname) > 220)
		{
			$dirname = substr($dirname, 0, 220);
		}

		return $dirname;
	}

	/**
	 * Retourne un nom de fichier valide à partir de celui fourni.
	 *
	 * @param string $filename
	 *   Nom de fichier.
	 *
	 * @return string
	 */
	public static function getValidFilename(string $filename): string
	{
		$filename = Utility::UTF8($filename);
		$filename = Utility::removeAccents($filename, TRUE);
		$filename = preg_replace('`([^-_a-z0-9.]|\.(?![^.]+$))`ui', '_', $filename);

		if (strlen($filename) > 220)
		{
			$parts = preg_split('`\.(?=[^.]+$)`', $filename);
			if ($parts[0] === '')
			{
				$filename = substr($filename, 0, 220);
			}
			else
			{
				$parts[0] = substr($parts[0], 0, 200);
				$parts[1] = substr($parts[1], 0, 20);
				$filename = $parts[0] . '.' . $parts[1];
			}
		}

		return $filename;
	}

	/**
	 * Hashage MD5 d'un nom de fichier.
	 *
	 * @param string $id
	 *   Identifiant ou nom de de fichier.
	 * @param array $params
	 *   Paramètres supplémentaire à ajouter à la signature.
	 *
	 * @return string
	 */
	public static function hashFilename(string $id, array $params = []): string
	{
		return md5((string) $id . '|' . CONF_KEY . '|' . implode('|', $params));
	}

	/**
	 * Envoi un code de réponse HTTP et retourne le message
	 * correspondant à ce code.
	 *
	 * @param int $code
	 *   Code de réponse HTTP.
	 *
	 * @return string
	 */
	public static function httpResponse(int $code): string
	{
		$line = CONF_DEBUG_MODE ? '[' . debug_backtrace()[0]['line'] . '] ' : '';

		switch ($code)
		{
			case 400 :
				header('HTTP/1.1 400 Bad Request');
				return $line . 'Bad request.';

			case 403 :
				header('HTTP/1.1 403 Forbidden');
				return $line . 'Forbidden.';

			case 404 :
				header('HTTP/1.1 404 Not Found');
				return $line . 'Not found.';

			case 500 :
				header('HTTP/1.1 500 Internal Server Error');
				return $line . 'Internal Server Error.';
		}
	}

	/**
	 * Enregistre l'activité de l'utilisateur.
	 *
	 * @param string $action
	 *   Action effectuée par l'utilisateur.
	 * @param string $match
	 *   Élément qui a été trouvée par une liste noire.
	 * @param array $post
	 *   Données en POST.
	 * @param int $user_id
	 *   Identifiant de l'utilisateur.
	 *   Si non fourni, utilise celui de l'utilisateur connecté.
	 *
	 * @return void
	 */
	public static function logActivity(string $action,
	string $match = '', array $post = [], int $user_id = 0): void
	{
		if (!Config::$params['users_log_activity']
		|| (Config::$params['users_log_activity_no_admin'] && Auth::$isAdmin))
		{
			return;
		}

		if (Config::$params['users_log_activity_rejected_only']
		&& !strstr($action, '_rejected'))
		{
			return;
		}

		if ($post)
		{
			foreach ($post as $k => &$v)
			{
				if (Utility::isEmpty((string) $v))
				{
					unset($post[$k]);
					continue;
				}
				Utility::strLimit((string) $v, 255);
			}
			$post = Utility::jsonEncode($post);
		}

		$sql = 'INSERT INTO {users_logs}
			   (user_id, log_page, log_date, log_action, log_match, log_post, log_ip)
			   VALUES
			   (:user_id, :page, NOW(), :action, :match, :post, :ip)';
		$params =
		[
			'action' => $action,
			'ip' => $_SERVER['REMOTE_ADDR'],
			'match' => mb_substr($match, 0, 255),
			'page' => in_array('Ajax', get_declared_classes()) ? 'ajax' : ($_GET['q'] ?? ''),
			'post' => is_string($post) ? $post : NULL,
			'user_id' => $user_id ? $user_id : Auth::$id
		];
		DB::execute($sql, $params);
	}

	/**
	 * Redirige vers une autre page de l'application.
	 *
	 * @param string $page
	 *   Page vers laquelle rediriger.
	 * @param int $http_response_code
	 *   Code de réponse HTTP.
	 *
	 * @return void
	 */
	public static function redirect(string $page = '', int $http_response_code = 302): void
	{
		// Permet de connaître toutes les redirections en mode débogage.
		if (CONF_DEBUG_MODE)
		{
			$trace = debug_backtrace();
			$file = (strpos($trace[0]['file'], GALLERY_ROOT) === 0)
				? substr($trace[0]['file'], strlen(GALLERY_ROOT) + 1)
				: $trace[0]['file'];
			trigger_error('Redirect in ' . $file . ':' . $trace[0]['line'], E_USER_NOTICE);
		}

		// Vérification du format de l'URL générée.
		$url = GALLERY_HOST . self::getURL($page);
		$url = str_replace('../', '', $url);
		if (!preg_match('`^' . Utility::URLRegexp() . '`i', $url))
		{
			trigger_error('No valid URL: ' . $url, E_USER_WARNING);
			return;
		}

		// Redirection.
		header('Location: ' . $url, TRUE, $http_response_code);
		die;
	}

	/**
	 * Extraction des paramètres de la requête GET à partir de modèles.
	 *
	 * Exemple :
	 *   Page demandée :
	 *      ?q=album/12/page/4
	 *   Modèle correspondant :
	 *      album/{id}
	 *   Regexp créée à partir du modèle qui sera testée sur le paramètre "q" :
	 *      ^album/([1-9]|\d{2,12})(?:-[^/]{1,255})?(?:/(page)/([1-9]|\d{2,12}))?$
	 *
	 * Le paramètre "page" est toujours ajouté à la fin du modèle pour
	 * éviter de devoir répéter ce paramètre pour de nombreux modèles.
	 *
	 * @param array $patterns
	 *
	 * @return void
	 */
	public static function request(array $patterns): void
	{
		$_GET['page'] = 1;

		if (!isset($_GET['q']))
		{
			return;
		}

		// On crée un tableau de tous les paramètres.
		$q = explode('/', $_GET['q']);

		// On parcours le tableau des modèles.
		foreach ($patterns as $i => &$pattern)
		{
			if (strstr($pattern, '{category}'))
			{
				$patterns[] = str_replace('{category}', 'album', $pattern);
				$patterns[] = str_replace('{category}', 'category', $pattern);
				unset($patterns[$i]);
			}
		}
		foreach ($patterns as &$pattern)
		{
			// Si la section principale ne correspond pas au modèle,
			// inutile d'aller plus loin.
			if (substr($pattern, 0, strlen($q[0])) != $q[0])
			{
				continue;
			}

			// On crée la regexp correspondant au modèle.
			$pattern = explode('/', $pattern . '/{page}');
			for ($i = 0, $params = [''], $regex = ''; $i < count($pattern); $i++)
			{
				switch ($pattern[$i])
				{
					case '{date}' :
						$params[] = 'date';
						$regex .= '/(\d{4}-\d{2}-\d{2})';
						break;

					case '{date-flex}' :
						$params[] = 'date';
						$regex .= '/(\d{4}(?:-\d{2}){0,2})';
						break;

					case '{id}' :
						$p = explode('-', $pattern[$i - 1]);
						if (!strstr($p[0], '{'))
						{
							$params[] = $p[0] . '_id';
						}
						$regex .= '/([1-9]|\d{2,12})(?:-[^/]{1,255})?';
						break;

					case '{ids}' :
						$p = explode('-', $pattern[$i - 1]);
						if (!strstr($p[0], '{'))
						{
							$params[] = $p[0] . '_ids';
						}
						$regex .= '/((?:(?:[1-9]|\d{2,12}),)+(?:[1-9]|\d{2,12}))';
						break;

					case '{ip}' :
						$params[] = 'ip';
						$regex .= '/(' . Utility::URLRegexp('ip') . ')';
						break;

					case '{key}' :
						$params[] = 'key';
						$regex .= '/([\da-zA-Z]{8,40})';
						break;

					case '{page}' :
						$params[] = '';
						$params[] = 'page';
						$regex .= '(?:/(page)/([1-9]|\d{2,12}))?';
						break;

					case '{search}' :
						$params[] = 'search';
						$regex .= '/([\da-zA-Z]{12})';
						break;

					case '{timestamp}' :
						$params[] = 'timestamp';
						$regex .= '/(\d{10})';
						break;

					default :
						$params[] = '';
						$regex .= '/(' . str_replace('(', '(?:', $pattern[$i]) . ')';
				}
			}

			// On teste la regexp.
			if (preg_match('`^' . substr($regex, 1) . '$`', $_GET['q'], $m))
			{
				// Si ça correspond, on crée les paramètres GET
				// à partir des chaînes capturées par la regexp.
				for ($i = 0; $i < count($params); $i++)
				{
					if (strstr($params[$i], '(') && substr($params[$i], -3) == '_id')
					{
						if (preg_match('`' . substr($params[$i], 0, -3) . '`', $_GET['q'], $m2))
						{
							$params[$i] = str_replace('-', '_', $m2[0]) . '_id';
						}
					}
					if (isset($m[$i]) && $params[$i] != '')
					{
						if (substr($params[$i], -3) == '_id')
						{
							$_GET[$params[$i]] = (int) $m[$i];
						}
						else
						{
							$_GET[$params[$i]] = $m[$i];
						}
					}
				}
				for ($i = $s = $v = 1; $i < count($m); $i++)
				{
					if ($m[$i] == 'page' || (isset($m[$i - 1]) && $m[$i - 1] == 'page'))
					{
						continue;
					}
					$_GET['params'][] = $m[$i];
					if ($params[$i] == '')
					{
						$_GET['section_' . $s++] = $m[$i];
					}
					else
					{
						$_GET['value_' . $v++] = $m[$i];
					}
				}
				$_GET['q_pageless'] = preg_replace('`/page/\d+$`', '', $_GET['q']);
				$_GET['section'] = $q[0];
				return;
			}
		}

		// On supprime la requête si elle ne correspond à aucun modèle.
		unset($_GET['q']);
	}
}
?>