File src/include/lib/Garradin/Fichiers.php artifact 76103311d2 part of check-in eba7bd3cf5


<?php

namespace Garradin;

use KD2\Graphics\Image;
use Garradin\Membres\Session;

class Fichiers
{
	public $id;
	public $nom;
	public $type;
	public $image;
	public $datetime;
	public $hash;
	public $taille;
	public $id_contenu;

	/**
	 * Tailles de miniatures autorisées, pour ne pas avoir 500 fichiers générés avec 500 tailles différentes
	 * @var array
	 */
	protected static $allowed_thumb_sizes = [200, 500];

	const LIEN_COMPTA = 'acc_transactions';
	const LIEN_WIKI = 'wiki_pages';
	const LIEN_MEMBRES = 'membres';

	/**
	 * Renvoie l'URL vers un fichier
	 * @param  integer $id   Numéro du fichier
	 * @param  string  $nom  Nom de fichier avec extension
	 * @param  integer $size Taille de la miniature désirée (pour les images)
	 * @return string        URL du fichier
	 */
	static public function _getURL(int $id, string $nom, string $hash, $size = false): string
	{
		$url = sprintf('%sf/%s/%s?', WWW_URL, base_convert((int)$id, 10, 36), $nom);

		if ($size)
		{
			$url .= self::_findThumbSize($size) . 'px&';
		}

		$url .= substr($hash, 0, 10);

		return $url;
	}

	/**
	 * Renvoie la taille de miniature la plus proche de la taille demandée
	 * @param  integer $size Taille demandée
	 * @return integer       Taille possible
	 */
	static protected function _findThumbSize($size)
	{
		$size = (int) $size;

		if (in_array($size, self::$allowed_thumb_sizes))
		{
			return $size;
		}

		foreach (self::$allowed_thumb_sizes as $s)
		{
			if ($s >= $size)
			{
				return $s;
			}
		}

		return max(self::$allowed_thumb_sizes);
	}

	/**
	 * Constructeur de l'objet pour un fichier
	 * @param integer $id Numéro unique du fichier
	 * @param $data array|object File data to populate object
	 */
	public function __construct(int $id, $data = null)
	{
		if (is_null($data))
		{
			$data = DB::getInstance()->first('SELECT fichiers.*, fc.hash, fc.taille,
				strftime(\'%s\', datetime) AS datetime
				FROM fichiers INNER JOIN fichiers_contenu AS fc ON fc.id = fichiers.id_contenu
				WHERE fichiers.id = ?;', (int)$id);
		}

		if (!$data)
		{
			throw new \InvalidArgumentException('Ce fichier n\'existe pas.');
		}

		foreach ((array)$data as $key => $value)
		{
			$this->$key = $value;
		}
	}

	/**
	 * Renvoie l'adresse d'accès au fichier
	 * @param  boolean $size Taille éventuelle de la miniature demandée
	 * @return string        URL d'accès au fichier
	 */
	public function getURL($size = false)
	{
		return self::_getURL($this->id, $this->nom, $this->hash, $size);
	}

	/**
	 * Lier un fichier à un contenu
	 * @param  string $type       Type de contenu (constantes LIEN_*)
	 * @param  integer $foreign_id ID du contenu lié
	 * @return boolean TRUE en cas de succès
	 */
	public function linkTo($type, $foreign_id)
	{
		$db = DB::getInstance();
		$check = [self::LIEN_MEMBRES, self::LIEN_WIKI, self::LIEN_COMPTA];

		if (!in_array($type, $check))
		{
			throw new \LogicException('Type de lien de fichier inconnu.');
		}

		// Vérifier que le fichier n'est pas déjà lié à un autre type
		$query = [];

		foreach ($check as $check_type)
		{
			// Ne pas chercher dans le type qu'on veut lier
			if ($check_type == $type)
			{
				continue;
			}

			$query[] = sprintf('SELECT 1 FROM fichiers_%s WHERE fichier = %d', $check_type, $this->id);
		}

		$query = implode(' UNION ', $query) . ';';

		if ($db->firstColumn($query))
		{
			throw new \LogicException('Ce fichier est déjà lié à un autre contenu : ' . $check_type);
		}

		return $db->preparedQuery('INSERT OR IGNORE INTO fichiers_' . $type . ' (fichier, id) VALUES (?, ?);',
			[(int)$this->id, (int)$foreign_id]);
	}

	public function getLinkedId(string $type)
	{
		$check = [self::LIEN_MEMBRES, self::LIEN_WIKI, self::LIEN_COMPTA];

		if (!in_array($type, $check))
		{
			throw new \LogicException('Type de lien de fichier inconnu.');
		}


		return DB::getInstance()->firstColumn(sprintf('SELECT id FROM fichiers_%s WHERE fichier = %d;', $type, $this->id));
	}

	public function isPublic(&$wiki = null): bool
	{
		$config = Config::getInstance();

		if ($config->get('image_fond') == $this->id)
		{
			return true;
		}

		// On regarde déjà si le fichier n'est pas lié au wiki
		$query = sprintf('SELECT wp.droit_lecture FROM fichiers_%s AS link
			INNER JOIN wiki_pages AS wp ON wp.id = link.id
			WHERE link.fichier = ? LIMIT 1;', self::LIEN_WIKI);
		$wiki = DB::getInstance()->firstColumn($query, (int)$this->id);

		// Page wiki publique, aucune vérification à faire, seul cas d'accès à un fichier en dehors de l'espace admin
		if ($wiki !== false && $wiki == Wiki::LECTURE_PUBLIC)
		{
			return true;
		}

		return false;
	}

	/**
	 * Vérifie que l'utilisateur a bien le droit d'accéder à ce fichier
	 * @param  mixed   $user Tableau contenant les infos sur l'utilisateur connecté, provenant de Session::getUser, ou false
	 * @return boolean       TRUE si l'utilisateur a le droit d'accéder au fichier, sinon FALSE
	 */
	public function checkAccess(Session $session, bool $require_admin = false)
	{
		$wiki = null;

		if ($this->isPublic($wiki) && !$require_admin) {
			return true;
		}

		// Pas d'utilisateur connecté, pas d'accès aux fichiers de l'espace admin
		if (!$session->isLogged())
		{
			return false;
		}

		$user = $session->getUser();

		if ($wiki !== false)
		{
			// S'il n'a même pas droit à accéder au wiki c'est mort
			if (!$session->canAccess('wiki', Membres::DROIT_ACCES))
			{
				return false;
			}

			// On renvoie à l'objet Wiki pour savoir si l'utilisateur a le droit de lire ce fichier
			$_w = new Wiki;
			$_w->setRestrictionCategorie($user->id_categorie, $user->droit_wiki);
			return $require_admin ? $_w->canWritePage($wiki) : $_w->canReadPage($wiki);
		}

		$level = $require_admin ? Membres::DROIT_ADMIN : Membres::DROIT_ACCES;

		$db = DB::getInstance();

		// On regarde maintenant si le fichier est lié à la compta
		$query = sprintf('SELECT 1 FROM fichiers_%s WHERE fichier = ? LIMIT 1;', self::LIEN_COMPTA);
		$compta = $db->firstColumn($query, (int)$this->id);

		if ($compta)
		{
			// OK si accès à la compta
			return $session->canAccess('compta', $level);
		}

		// Enfin, si le fichier est lié à un membre
		$query = sprintf('SELECT id FROM fichiers_%s WHERE fichier = ? LIMIT 1;', self::LIEN_MEMBRES);
		$membre = $db->firstColumn($query, (int)$this->id);

		if ($membre !== false)
		{
			// De manière évidente, l'utilisateur a le droit d'accéder aux fichiers liés à son profil
			if ((int)$membre == $user->id)
			{
				return true;
			}

			// Pour voir les fichiers des membres il faut pouvoir les gérer
			if ($level == Membres::DROIT_ACCES) {
				$level = Membres::DROIT_ECRITURE;
			}

			if ($session->canAccess('membres', $level))
			{
				return true;
			}
		}

		return false;
	}

	/**
	 * Supprime le fichier
	 * @return boolean TRUE en cas de succès
	 */
	public function remove()
	{
		$db = DB::getInstance();
		$db->begin();
		$db->delete('fichiers_' . self::LIEN_COMPTA, 'fichier = ?', (int)$this->id);
		$db->delete('fichiers_' . self::LIEN_WIKI, 'fichier = ?', (int)$this->id);
		$db->delete('fichiers_' . self::LIEN_MEMBRES, 'fichier = ?', (int)$this->id);

		$db->delete('fichiers', 'id = ?', (int)$this->id);

		// Suppression du contenu s'il n'est pas utilisé par un autre fichier
		if (!$db->firstColumn('SELECT 1 FROM fichiers WHERE id_contenu = ? AND id != ? LIMIT 1;', 
			(int)$this->id_contenu, (int)$this->id))
		{
			$db->delete('fichiers_contenu', 'id = ?', (int)$this->id_contenu);
		}

		$cache_id = 'fichiers.' . $this->id_contenu;
		
		Static_Cache::remove($cache_id);

		foreach (self::$allowed_thumb_sizes as $size)
		{
			Static_Cache::remove($cache_id . '.thumb.' . (int)$size);
		}

		return $db->commit();
	}

	/**
	 * Renvoie le chemin vers le fichier local en cache, et le crée s'il n'existe pas
	 * @return string Chemin local
	 */
	protected function getFilePathFromCache()
	{
		// Le cache est géré par ID contenu, pas ID fichier, pour minimiser l'espace disque utilisé
		$cache_id = 'fichiers.' . $this->id_contenu;

		// Le fichier n'existe pas dans le cache statique, on l'enregistre
		if (!Static_Cache::exists($cache_id))
		{
			$blob = DB::getInstance()->openBlob('fichiers_contenu', 'contenu', (int)$this->id_contenu);
			Static_Cache::storeFromPointer($cache_id, $blob);
			fclose($blob);
		}

		return Static_Cache::getPath($cache_id);
	}

	/**
	 * Envoie le fichier au client HTTP
	 * @return void
	 */
	public function serve()
	{
		return $this->_serve($this->getFilePathFromCache(), $this->type, ($this->image ? false : $this->nom), $this->taille, $this->isPublic());
	}

	/**
	 * Envoie une miniature à la taille indiquée au client HTTP
	 * @return void
	 */
	public function serveThumbnail($width = null)
	{
		if (!$this->image)
		{
			throw new UserException('Il n\'est pas possible de fournir une miniature pour un fichier qui n\'est pas une image.');
		}

		if (!$width)
		{
			$width = reset(self::$allowed_thumb_sizes);
		}

		if (!in_array($width, self::$allowed_thumb_sizes))
		{
			throw new UserException('Cette taille de miniature n\'est pas autorisée.');
		}

		$cache_id = 'fichiers.' . $this->id_contenu . '.thumb.' . (int)$width;
		$path = Static_Cache::getPath($cache_id);

		// La miniature n'existe pas dans le cache statique, on la crée
		if (!Static_Cache::exists($cache_id))
		{
			$source = $this->getFilePathFromCache();

			try {
				(new Image($source))->resize($width)->save($path);
			}
			catch (\RuntimeException $e) {
				throw new UserException('Impossible de créer la miniature');
			}
		}

		return $this->_serve($path, $this->type);
	}

	/**
	 * Servir un fichier local en HTTP
	 * @param  string $path Chemin vers le fichier local
	 * @param  string $type Type MIME du fichier
	 * @param  string $name Nom du fichier avec extension
	 * @param  integer $size Taille du fichier en octets (facultatif)
	 * @return boolean TRUE en cas de succès
	 */
	protected function _serve($path, $type, $name = false, $size = null, bool $public = false)
	{
		if ($public) {
			Utils::HTTPCache($this->hash, $this->datetime);
		}
		else {
			// Désactiver le cache
			header('Pragma: public');
			header('Expires: -1');
			header('Cache-Control: public, must-revalidate, post-check=0, pre-check=0');
		}

		header('Content-Type: '.$type);

		if ($name)
		{
			header('Content-Disposition: attachment; filename="' . $name . '"');
		}

		// Utilisation de XSendFile si disponible
		if (ENABLE_XSENDFILE && isset($_SERVER['SERVER_SOFTWARE']))
		{
			if (stristr($_SERVER['SERVER_SOFTWARE'], 'apache') 
				&& function_exists('apache_get_modules') 
				&& in_array('mod_xsendfile', apache_get_modules()))
			{
				header('X-Sendfile: ' . $path);
				return true;
			}
			else if (stristr($_SERVER['SERVER_SOFTWARE'], 'lighttpd'))
			{
				header('X-Sendfile: ' . $path);
				return true;
			}
		}

		// Désactiver gzip
		if (function_exists('apache_setenv'))
		{
			@apache_setenv('no-gzip', 1);
		}

		@ini_set('zlib.output_compression', 'Off');

		if ($size)
		{
			header('Content-Length: '. (int)$size);
		}

		if (@ob_get_length()) {
			@ob_clean();
		}

		flush();

		// Sinon on envoie le fichier à la mano
		return readfile($path);
	}

	/**
	 * Vérifie si le hash fourni n'est pas déjà stocké
	 * Utile pour par exemple reconnaître un ficher dont le contenu est déjà stocké, et éviter un nouvel upload
	 * @param  string $hash Hash SHA1
	 * @return boolean      TRUE si le hash est déjà présent dans fichiers_contenu, FALSE sinon
	 */
	static public function checkHash($hash)
	{
		return (boolean) DB::getInstance()->firstColumn(
			'SELECT 1 FROM fichiers_contenu WHERE hash = ?;', 
			trim(strtolower($hash))
		);
	}

	/**
	 * Retourne un tableau de hash trouvés dans la DB parmi une liste de hash fournis
	 * @param  array  $list Liste de hash à vérifier
	 * @return array        Liste des hash trouvés
	 */
	static public function checkHashList($list)
	{
		$db = DB::getInstance();

		array_walk($list, function (&$a) use ($db) {
			$a = $db->quote($a);
		});

		$query = sprintf('SELECT hash, 1 FROM fichiers_contenu WHERE hash IN (%s);', 
			implode(', ', $list));
		return $db->getAssoc($query);
	}

	/**
	 * Récupération du message d'erreur
	 * @param  integer $error Code erreur du $_FILE
	 * @return string Message d'erreur
	 */
	static public function getErrorMessage($error)
	{
		switch ($error)
		{
			case UPLOAD_ERR_INI_SIZE:
				return 'Le fichier excède la taille permise par la configuration du serveur.';
			case UPLOAD_ERR_FORM_SIZE:
				return 'Le fichier excède la taille permise par le formulaire.';
			case UPLOAD_ERR_PARTIAL:
				return 'L\'envoi du fichier a été interrompu.';
			case UPLOAD_ERR_NO_FILE:
				return 'Aucun fichier n\'a été reçu.';
			case UPLOAD_ERR_NO_TMP_DIR:
				return 'Pas de répertoire temporaire pour stocker le fichier.';
			case UPLOAD_ERR_CANT_WRITE:
				return 'Impossible d\'écrire le fichier sur le disque du serveur.';
			case UPLOAD_ERR_EXTENSION:
				return 'Une extension du serveur a interrompu l\'envoi du fichier.';
			default:
				return 'Erreur inconnue: ' . $error;
		}
	}

	/**
	 * Upload du fichier par POST
	 * @param  array  $file  Caractéristiques du fichier envoyé
	 * @return Fichiers
	 */
	static public function upload($file)
	{
		if (!empty($file['error']))
		{
			throw new UserException(self::getErrorMessage($file['error']));
		}

		if (empty($file['size']) || empty($file['name']))
		{
			throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
		}

		if (!is_uploaded_file($file['tmp_name']))
		{
			throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
		}

		$name = preg_replace('/\s+/', '_', $file['name']);
		$name = preg_replace('/[^\d\w._-]/ui', '', $name);

		return self::storeFile($name, $file['tmp_name']);
	}

	/**
	 * Upload de fichier à partir d'une chaîne en base64
	 * @param  string $name
	 * @param  string $content
	 * @return Fichiers
	 */
	static public function storeFromBase64($name, $content)
	{
		$content = base64_decode($content);
		return self::storeFile($name, null, $content);
	}

	/**
	 * Upload de fichier (interne)
	 *
	 * @param  string $name
	 * @param  string $path Chemin du fichier
	 * @param  string $content Ou contenu du fichier
	 * @return Fichiers
	 */
	static protected function storeFile($name, $path = null, $content = null)
	{
		assert($path || $content);

		if ($path && !$content)
		{
			$hash = sha1_file($path);
			$size = filesize($path);
			$bytes = file_get_contents($path, false, null, -1, 1024);
		}
		else
		{
			$hash = sha1($content);
			$size = strlen($content);
			$bytes = substr($content, 0, 1024);
		}

		$type = \KD2\FileInfo::guessMimeType($bytes);

		if (!$type)
		{
			$ext = substr($name, strrpos($name, '.')+1);
			$ext = strtolower($ext);

			$type = \KD2\FileInfo::getMimeTypeFromFileExtension($ext);
		}

		$is_image = preg_match('/^image\/(?:png|jpe?g|gif)$/', $type);

		// Check that it's a real image
		if ($is_image) {
			try {
				if ($path && !$content) {
					$i = new Image($path);
				}
				else {
					$i = Image::createFromBlob($content);
				}

				// Recompress PNG files from base64, assuming they are coming
				// from JS canvas which doesn't know how to gzip (d'oh!)
				if ($i->format() == 'png' && null !== $content) {
					$content = $i->output('png', true);
					$hash = sha1($content);
					$size = strlen($content);
				}

				unset($i);
			}
			catch (\RuntimeException $e) {
				if (strstr($e->getMessage(), 'No suitable image library found')) {
					throw new UserException('Le serveur n\'a aucune bibliothèque de gestion d\'image installée, et ne peut donc pas accepter les images. Installez Imagick ou GD.');
				}

				throw new UserException('Fichier image invalide');
			}
		}

		$db = DB::getInstance();

		$db->begin();

		// Il peut arriver que l'on renvoie ici un fichier déjà stocké, auquel cas, ne pas le re-stocker
		if (!($id_contenu = $db->firstColumn('SELECT id FROM fichiers_contenu WHERE hash = ?;', $hash))) {
			$db->preparedQuery('INSERT INTO fichiers_contenu (hash, taille, contenu) VALUES (?, ?, zeroblob(?));',
				[$hash, (int)$size, (int)$size]);
			$id_contenu = $db->lastInsertRowID();

			// Écrire le contenu
			$blob = $db->openBlob('fichiers_contenu', 'contenu', $id_contenu, 'main', SQLITE3_OPEN_READWRITE);

			if (null !== $content) {
				fwrite($blob, $content);
			}
			else{
				fwrite($blob, file_get_contents($path));
			}

			fclose($blob);
		}

		$db->insert('fichiers', [
			'id_contenu'	=>	(int)$id_contenu,
			'nom'			=>	$name,
			'type'			=>	$type,
			'image'			=>	(int)$is_image,
		]);

		$db->commit();

		return new Fichiers($db->lastInsertRowID());
	}

	/**
	 * Envoie un fichier déjà stocké
	 * 
	 * @param  string $name Nom du fichier
	 * @param  string $hash Hash SHA1 du contenu du fichier
	 * @return object       Un objet Fichiers en cas de succès
	 */
	static public function uploadExistingHash($name, $hash)
	{
		$db = DB::getInstance();
		$name = preg_replace('/[^\d\w._-]/ui', '', $name);

		$file = $db->first('SELECT * FROM fichiers 
			INNER JOIN fichiers_contenu AS fc ON fc.id = fichiers.id_contenu AND fc.hash = ?;', trim($hash));

		if (!$file)
		{
			throw new UserException('Le fichier à copier n\'existe pas (aucun hash ne correspond à '.$hash.').');
		}

		$db->insert('fichiers', [
			'id_contenu' =>	(int)$file->id_contenu,
			'nom'        =>	$name,
			'type'       =>	$file->type,
			'image'      =>	(int)$file->image,
		]);

		return new Fichiers($db->lastInsertRowID());
	}

	/**
	 * Récupère la liste des fichiers liés à une ressource
	 * 
	 * @param  string  $type    Type de ressource
	 * @param  integer $id      Numéro de ressource
	 * @param  boolean|null $images  TRUE pour retourner seulement les images,
	 * FALSE pour retourner les fichiers sans images, NULL pour tout retourner
	 * @return array          Liste des fichiers
	 */
	static public function listLinkedFiles($type, $id, $images = null)
	{
		$check = [self::LIEN_MEMBRES, self::LIEN_WIKI, self::LIEN_COMPTA];

		if (!in_array($type, $check))
		{
			throw new \LogicException('Type de lien de fichier inconnu.');
		}

		$images = is_null($images) ? '' : ' AND image = ' . (int)$images;

		$query = sprintf('SELECT fichiers.*, c.hash, c.taille
			FROM fichiers 
			INNER JOIN fichiers_%s AS fwp ON fwp.fichier = fichiers.id
			INNER JOIN fichiers_contenu AS c ON c.id = fichiers.id_contenu
			WHERE fwp.id = ? %s
			ORDER BY fichiers.nom COLLATE NOCASE;', $type, $images);

		$files = DB::getInstance()->get($query, (int)$id);

		foreach ($files as &$file)
		{
			$file->url = self::_getURL($file->id, $file->nom, $file->hash);
			$file->thumb = $file->image ? self::_getURL($file->id, $file->nom, $file->hash, 200) : false;
		}

		return $files;
	}

	static public function deleteLinkedFiles($type, int $id)
	{
		static $check = [self::LIEN_MEMBRES, self::LIEN_WIKI, self::LIEN_COMPTA];

		if (!in_array($type, $check))
		{
			throw new \LogicException('Type de lien de fichier inconnu.');
		}

		$files = DB::getInstance()->delete('fichiers_' . $type, 'id = ?', $id);
		return self::deleteUnlinkedFiles();
	}

	static public function deleteUnlinkedFiles()
	{
		static $all = [self::LIEN_MEMBRES, self::LIEN_WIKI, self::LIEN_COMPTA];
		$id_background = Config::getInstance()->get('image_fond');

		$list = DB::getInstance()->iterate(sprintf('SELECT f.id, f.id_contenu FROM fichiers f
			LEFT JOIN fichiers_%s a ON a.fichier = f.id
			LEFT JOIN fichiers_%s b ON b.fichier = f.id
			LEFT JOIN fichiers_%s c ON c.fichier = f.id
			WHERE a.id IS NULL AND b.id IS NULL AND c.id IS NULL;',
			self::LIEN_MEMBRES,
			self::LIEN_WIKI,
			self::LIEN_COMPTA));

		foreach ($list as $file) {
			if ($file->id == $id_background) { // FIXME: want to use something cleaner here!
				continue;
			}

			$f = new Fichiers($file->id, (array) $file);
			$f->remove();
		}
	}

	/**
	 * Enlève d'une liste de fichiers ceux qui sont mentionnés dans un texte wiki
	 * @param  array $files Liste de fichiers
	 * @param  string $text  texte wiki
	 * @return array        Un tableau qui ne contient pas les fichiers mentionnés dans $text
	 */
	static public function filterFilesUsedInText($files, $text)
	{
		$used = self::listFilesUsedInText($text);

		return array_filter($files, function ($row) use ($used) {
			return !in_array($row->id, $used);
		});
	}

	/**
	 * Renvoie une liste d'ID de fichiers mentionnées dans un texte wiki
	 * @param  string $text Texte wiki
	 * @return array       Liste des IDs de fichiers mentionnés
	 */
	static public function listFilesUsedInText($text)
	{
		preg_match_all('/<<?(?:fichier|image)\s*(?:\|\s*)?(\d+)/', $text, $match, PREG_PATTERN_ORDER);
		preg_match_all('/(?:fichier|image):\/\/(\d+)/', $text, $match2, PREG_PATTERN_ORDER);

		return array_merge($match[1], $match2[1]);
	}

	/**
	 * Callback utilisé pour l'extension <<fichier>> dans le wiki-texte
	 * @param array $args    Arguments passés à l'extension
	 * @param string $content Contenu éventuel (en mode bloc)
	 * @param object $skriv   Objet SkrivLite
	 */
	static public function SkrivFichier($args, $content, $skriv)
	{
		$id = $caption = null;

		foreach ($args as $value)
		{
			if (preg_match('/^\d+$/', $value) && !$id)
			{
				$id = (int)$value;
				break;
			}
			else
			{
				$caption = trim($value);
			}
		}

		if (empty($id))
		{
			return $skriv->parseError('/!\ Tag fichier : aucun numéro de fichier indiqué.');
		}

		try {
			$file = new Fichiers($id);
		}
		catch (\InvalidArgumentException $e)
		{
			return $skriv->parseError('/!\ Tag fichier : ' . $e->getMessage());
		}

		if (empty($caption))
		{
			$caption = $file->nom;
		}

		$out = '<aside class="fichier" data-type="'.$skriv->escape($file->type).'">';
		$out.= '<a href="'.$file->getURL().'" class="internal-file">'.$skriv->escape($caption).'</a> ';
		$out.= '<small>('.$skriv->escape(($file->type ? $file->type . ', ' : '') . Utils::format_bytes($file->taille)).')</small>';
		$out.= '</aside>';
		return $out;
	}

	/**
	 * Callback utilisé pour l'extension <<image>> dans le wiki-texte
	 * @param array $args    Arguments passés à l'extension
	 * @param string $content Contenu éventuel (en mode bloc)
	 * @param object $skriv   Objet SkrivLite
	 */
	static public function SkrivImage($args, $content, $skriv)
	{
		static $align_values = ['droite', 'gauche', 'centre'];

		$align = '';
		$id = $caption = null;

		foreach ($args as $value)
		{
			if (preg_match('/^\d+$/', $value) && !$id)
			{
				$id = (int)$value;
			}
			else if (in_array($value, $align_values) && !$align)
			{
				$align = $value;
			}
			else
			{
				$caption = $value;
			}
		}

		if (!$id)
		{
			return $skriv->parseError('/!\ Tag image : aucun numéro de fichier indiqué.');
		}

		try {
			$file = new Fichiers($id);
		}
		catch (\InvalidArgumentException $e)
		{
			return $skriv->parseError('/!\ Tag image : ' . $e->getMessage());
		}

		if (!$file->image)
		{
			return $skriv->parseError('/!\ Tag image : ce fichier n\'est pas une image.');
		}

		$out = '<a href="'.$file->getURL().'" class="internal-image">';
		$out .= '<img src="'.$file->getURL($align == 'centre' ? 500 : 200).'" alt="';

		if ($caption)
		{
			$out .= htmlspecialchars($caption, ENT_QUOTES, 'UTF-8');
		}

		$out .= '" /></a>';

		if (!empty($align))
		{
			$out = '<figure class="image ' . $align . '">' . $out;

			if ($caption)
			{
				$out .= '<figcaption>' . htmlspecialchars($caption, ENT_QUOTES, 'UTF-8') . '</figcaption>';
			}

			$out .= '</figure>';
		}

		return $out;
	}
}