Pārlūkot izejas kodu

Merge branch '252-extensions' into dev

Marien Fressinaud 11 gadi atpakaļ
vecāks
revīzija
250cd79251

+ 192 - 0
app/Controllers/extensionController.php

@@ -0,0 +1,192 @@
+<?php
+
+/**
+ * The controller to manage extensions.
+ */
+class FreshRSS_extension_Controller extends Minz_ActionController {
+	/**
+	 * This action is called before every other action in that class. It is
+	 * the common boiler plate for every action. It is triggered by the
+	 * underlying framework.
+	 */
+	public function firstAction() {
+		if (!FreshRSS_Auth::hasAccess()) {
+			Minz_Error::error(403);
+		}
+	}
+
+	/**
+	 * This action lists all the extensions available to the current user.
+	 */
+	public function indexAction() {
+		Minz_View::prependTitle(_t('admin.extensions.title') . ' · ');
+		$this->view->extension_list = Minz_ExtensionManager::list_extensions();
+	}
+
+	/**
+	 * This action handles configuration of a given extension.
+	 *
+	 * Only administrator can configure a system extension.
+	 *
+	 * Parameters are:
+	 * - e: the extension name (urlencoded)
+	 * - additional parameters which should be handle by the extension
+	 *   handleConfigureAction() method (POST request).
+	 */
+	public function configureAction() {
+		if (Minz_Request::param('ajax')) {
+			$this->view->_useLayout(false);
+		}
+
+		$ext_name = urldecode(Minz_Request::param('e'));
+		$ext = Minz_ExtensionManager::find_extension($ext_name);
+
+		if (is_null($ext)) {
+			Minz_Error::error(404);
+		}
+		if ($ext->getType() === 'system' && !FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+
+		$this->view->extension = $ext;
+		$this->view->extension->handleConfigureAction();
+	}
+
+	/**
+	 * This action enables a disabled extension for the current user.
+	 *
+	 * System extensions can only be enabled by an administrator.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * - e: the extension name (urlencoded).
+	 */
+	public function enableAction() {
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::find_extension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			if ($ext->is_enabled()) {
+				Minz_Request::bad(_t('feedback.extensions.already_enabled', $ext_name),
+				                  $url_redirect);
+			}
+
+			$conf = null;
+			if ($ext->getType() === 'system' && FreshRSS_Auth::hasAccess('admin')) {
+				$conf = FreshRSS_Context::$system_conf;
+			} elseif ($ext->getType() === 'user') {
+				$conf = FreshRSS_Context::$user_conf;
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name),
+				                  $url_redirect);
+			}
+
+			$ext->install();
+
+			$ext_list = $conf->extensions_enabled;
+			array_push_unique($ext_list, $ext_name);
+			$conf->extensions_enabled = $ext_list;
+			$conf->save();
+
+			Minz_Request::good(_t('feedback.extensions.enabled', $ext_name),
+			                  $url_redirect);
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+
+	/**
+	 * This action disables an enabled extension for the current user.
+	 *
+	 * System extensions can only be disabled by an administrator.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * - e: the extension name (urlencoded).
+	 */
+	public function disableAction() {
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::find_extension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			if (!$ext->is_enabled()) {
+				Minz_Request::bad(_t('feedback.extensions.not_enabled', $ext_name),
+				                  $url_redirect);
+			}
+
+			$conf = null;
+			if ($ext->getType() === 'system' && FreshRSS_Auth::hasAccess('admin')) {
+				$conf = FreshRSS_Context::$system_conf;
+			} elseif ($ext->getType() === 'user') {
+				$conf = FreshRSS_Context::$user_conf;
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name),
+				                  $url_redirect);
+			}
+
+			$ext->uninstall();
+
+			$ext_list = $conf->extensions_enabled;
+			array_remove($ext_list, $ext_name);
+			$conf->extensions_enabled = $ext_list;
+			$conf->save();
+
+			Minz_Request::good(_t('feedback.extensions.disabled', $ext_name),
+			                  $url_redirect);
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+
+	/**
+	 * This action handles deletion of an extension.
+	 *
+	 * Only administrator can remove an extension.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * -e: extension name (urlencoded)
+	 */
+	public function removeAction() {
+		if (!FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::find_extension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			$res = recursive_unlink($ext->getPath());
+			if ($res) {
+				Minz_Request::good(_t('feedback.extensions.removed', $ext_name),
+				                  $url_redirect);
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.cannot_delete', $ext_name),
+				                  $url_redirect);
+			}
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+}

+ 26 - 5
app/Controllers/feedController.php

@@ -142,6 +142,13 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			$feed->_category($cat);
 			$feed->_httpAuth($http_auth);
 
+			// Call the extension hook
+			$name = $feed->name();
+			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+			if (is_null($feed)) {
+				Minz_Request::bad(_t('feed_not_added', $name), $url_redirect);
+			}
+
 			$values = array(
 				'url' => $feed->url(),
 				'category' => $feed->category(),
@@ -178,10 +185,17 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			$feedDAO->beginTransaction();
 			foreach ($entries as $entry) {
 				// Entries are added without any verification.
+				$entry->_feed($feed->id());
+				$entry->_id(min(time(), $entry->date(true)) . uSecString());
+				$entry->_isRead($is_read);
+
+				$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
+				if (is_null($entry)) {
+					// An extension has returned a null value, there is nothing to insert.
+					continue;
+				}
+
 				$values = $entry->toArray();
-				$values['id_feed'] = $feed->id();
-				$values['id'] = min(time(), $entry->date(true)) . uSecString();
-				$values['is_read'] = $is_read;
 				$entryDAO->addEntry($values, $prepared_statement);
 			}
 			$feedDAO->updateLastUpdate($feed->id());
@@ -333,9 +347,16 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						$id = min(time(), $entry_date) . uSecString();
 					}
 
+					$entry->_id($id);
+					$entry->_isRead($is_read);
+
+					$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
+					if (is_null($entry)) {
+						// An extension has returned a null value, there is nothing to insert.
+						continue;
+					}
+
 					$values = $entry->toArray();
-					$values['id'] = $id;
-					$values['is_read'] = $is_read;
 					$entryDAO->addEntry($values, $prepared_statement);
 				}
 			}

+ 26 - 10
app/Controllers/importExportController.php

@@ -257,10 +257,16 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$feed->_website($website);
 			$feed->_description($description);
 
-			// addFeedObject checks if feed is already in DB so nothing else to
-			// check here
-			$id = $this->feedDAO->addFeedObject($feed);
-			$error = ($id === false);
+			// Call the extension hook
+			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+			if (!is_null($feed)) {
+				// addFeedObject checks if feed is already in DB so nothing else to
+				// check here
+				$id = $this->feedDAO->addFeedObject($feed);
+				$error = ($id === false);
+			} else {
+				$error = true;
+			}
 		} catch (FreshRSS_Feed_Exception $e) {
 			Minz_Log::warning($e->getMessage());
 			$error = true;
@@ -383,6 +389,12 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$entry->_id(min(time(), $entry->date(true)) . uSecString());
 			$entry->_tags($tags);
 
+			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
+			if (is_null($entry)) {
+				// An extension has returned a null value, there is nothing to insert.
+				continue;
+			}
+
 			$values = $entry->toArray();
 			$id = $this->entryDAO->addEntry($values, $prepared_statement);
 
@@ -419,13 +431,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$feed->_name($name);
 			$feed->_website($website);
 
-			// addFeedObject checks if feed is already in DB so nothing else to
-			// check here.
-			$id = $this->feedDAO->addFeedObject($feed);
+			// Call the extension hook
+			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+			if (!is_null($feed)) {
+				// addFeedObject checks if feed is already in DB so nothing else to
+				// check here.
+				$id = $this->feedDAO->addFeedObject($feed);
 
-			if ($id !== false) {
-				$feed->_id($id);
-				$return = $feed;
+				if ($id !== false) {
+					$feed->_id($id);
+					$return = $feed;
+				}
 			}
 		} catch (FreshRSS_Feed_Exception $e) {
 			Minz_Log::warning($e->getMessage());

+ 8 - 26
app/FreshRSS.php

@@ -6,6 +6,8 @@ class FreshRSS extends Minz_FrontController {
 			Minz_Session::init('FreshRSS');
 		}
 
+		// Load list of extensions and enable the "system" ones.
+		Minz_ExtensionManager::init();
 		$this->initConfiguration();
 		$this->initAuth();
 		FreshRSS_Context::init();
@@ -13,7 +15,11 @@ class FreshRSS extends Minz_FrontController {
 		FreshRSS_Share::load(join_path(DATA_PATH, 'shares.php'));
 		$this->loadStylesAndScripts();
 		$this->loadNotifications();
-		$this->loadExtensions();
+		// Enable extensions for the current (logged) user.
+		if (FreshRSS_Auth::hasAccess()) {
+			$ext_list = FreshRSS_Context::$user_conf->extensions_enabled;
+			Minz_ExtensionManager::enable_by_list($ext_list);
+		}
 	}
 
 	private function initConfiguration() {
@@ -46,11 +52,7 @@ class FreshRSS extends Minz_FrontController {
 
 	private function initI18n() {
 		Minz_Session::_param('language', FreshRSS_Context::$user_conf->language);
-
-		Minz_Translate::init(array(
-			'en' => 'English',
-			'fr' => 'Français',
-		), FreshRSS_Context::$user_conf->language);
+		Minz_Translate::init(FreshRSS_Context::$user_conf->language);
 	}
 
 	private function loadStylesAndScripts() {
@@ -91,24 +93,4 @@ class FreshRSS extends Minz_FrontController {
 			Minz_Session::_param('notification');
 		}
 	}
-
-	private function loadExtensions() {
-		$extensionPath = FRESHRSS_PATH . '/extensions/';
-		//TODO: Add a preference to load only user-selected extensions
-		foreach (scandir($extensionPath) as $key => $extension) {
-			if (ctype_alpha($extension)) {
-				$mtime = @filemtime($extensionPath . $extension . '/style.css');
-				if ($mtime !== false) {
-					Minz_View::appendStyle(Minz_Url::display('/ext.php?c&amp;e=' . $extension . '&amp;' . $mtime));
-				}
-				$mtime = @filemtime($extensionPath . $extension . '/script.js');
-				if ($mtime !== false) {
-					Minz_View::appendScript(Minz_Url::display('/ext.php?j&amp;e=' . $extension . '&amp;' . $mtime));
-				}
-				if (file_exists($extensionPath . $extension . '/module.php')) {
-					//TODO: include
-				} 
-			}
-		}
-	}
 }

+ 9 - 1
app/Models/ConfigurationSetter.php

@@ -67,6 +67,14 @@ class FreshRSS_ConfigurationSetter {
 		}
 	}
 
+	// It works for system config too!
+	private function _extensions_enabled(&$data, $value) {
+		if (!is_array($value)) {
+			$value = array($value);
+		}
+		$data['extensions_enabled'] = $value;
+	}
+
 	private function _html5_notif_timeout(&$data, $value) {
 		$value = intval($value);
 		$data['html5_notif_timeout'] = $value >= 0 ? $value : 0;
@@ -81,7 +89,7 @@ class FreshRSS_ConfigurationSetter {
 	private function _language(&$data, $value) {
 		$value = strtolower($value);
 		$languages = Minz_Translate::availableLanguages();
-		if (!isset($languages[$value])) {
+		if (!in_array($value, $languages)) {
 			$value = 'en';
 		}
 		$data['language'] = $value;

+ 6 - 0
app/i18n/en/admin.php

@@ -102,6 +102,12 @@ return array(
 			'ok' => 'You have ZIP extension.',
 		),
 	),
+	'extensions' => array(
+		'empty_list' => 'There is no installed extension',
+		'no_configure_view' => 'This extension cannot be configured.',
+		'system' => 'System extension (you have no rights on it)',
+		'title' => 'Extensions',
+	),
 	'stats' => array(
 		'_' => 'Statistics',
 		'all_feeds' => 'All feeds',

+ 8 - 0
app/i18n/en/feedback.php

@@ -29,6 +29,14 @@ return array(
 		'shortcuts_updated' => 'Shortcuts have been updated',
 		'updated' => 'Configuration has been updated',
 	),
+	'extensions' => array(
+		'already_enabled' => '%s is already enabled',
+		'disabled' => '%s is now disabled',
+		'enabled' => '%s is now enabled',
+		'no_access' => 'You have no access on %s',
+		'not_enabled' => '%s is not enabled yet',
+		'not_found' => '%s does not exist',
+	),
 	'import_export' => array(
 		'export_no_zip_extension' => 'Zip extension is not present on your server. Please try to export files one by one.',
 		'feeds_imported' => 'Your feeds have been imported and will now be updated',

+ 5 - 0
app/i18n/en/gen.php

@@ -100,6 +100,10 @@ return array(
 		'notif_title_new_articles' => 'FreshRSS: new articles!',
 		'should_be_activated' => 'JavaScript must be enabled',
 	),
+	'lang' => array(
+		'en' => 'English',
+		'fr' => 'Français',
+	),
 	'menu' => array(
 		'about' => 'About',
 		'admin' => 'Administration',
@@ -108,6 +112,7 @@ return array(
 		'check_install' => 'Installation checking',
 		'configuration' => 'Configuration',
 		'display' => 'Display',
+		'extensions' => 'Extensions',
 		'logs' => 'Logs',
 		'queries' => 'User queries',
 		'reading' => 'Reading',

+ 6 - 0
app/i18n/fr/admin.php

@@ -102,6 +102,12 @@ return array(
 			'ok' => 'Vous disposez de l\'extension ZIP.',
 		),
 	),
+	'extensions' => array(
+		'empty_list' => 'Il n’y a aucune extension installée.',
+		'no_configure_view' => 'Cette extension ne peut pas être configurée.',
+		'system' => 'Extension système (vous n’avez aucun droit dessus)',
+		'title' => 'Extensions',
+	),
 	'stats' => array(
 		'_' => 'Statistiques',
 		'all_feeds' => 'Tous les flux',

+ 8 - 0
app/i18n/fr/feedback.php

@@ -29,6 +29,14 @@ return array(
 		'shortcuts_updated' => 'Les raccourcis ont été mis à jour.',
 		'updated' => 'La configuration a été mise à jour',
 	),
+	'extensions' => array(
+		'already_enabled' => '%s est déjà activée',
+		'disabled' => '%s est désormais désactivée',
+		'enabled' => '%s est désormais activée',
+		'no_access' => 'Vous n’avez aucun accès sur %s',
+		'not_enabled' => '%s n’est pas encore activée',
+		'not_found' => '%s n’existe pas',
+	),
 	'import_export' => array(
 		'export_no_zip_extension' => 'L’extension Zip n’est pas présente sur votre serveur. Veuillez essayer d’exporter les fichiers un par un.',
 		'feeds_imported' => 'Vos flux ont été importés et vont maintenant être actualisés.',

+ 5 - 0
app/i18n/fr/gen.php

@@ -100,6 +100,10 @@ return array(
 		'notif_title_new_articles' => 'FreshRSS : nouveaux articles !',
 		'should_be_activated' => 'Le JavaScript doit être activé.',
 	),
+	'lang' => array(
+		'en' => 'English',
+		'fr' => 'Français',
+	),
 	'menu' => array(
 		'about' => 'À propos',
 		'admin' => 'Administration',
@@ -108,6 +112,7 @@ return array(
 		'check_install' => 'Vérification de l’installation',
 		'configuration' => 'Configuration',
 		'display' => 'Affichage',
+		'extensions' => 'Extensions',
 		'logs' => 'Logs',
 		'queries' => 'Filtres utilisateurs',
 		'reading' => 'Lecture',

+ 12 - 16
app/install.php

@@ -43,24 +43,18 @@ function param($key, $default = false) {
 
 // gestion internationalisation
 function initTranslate() {
-	$available_languages = array(
-		'en' => 'English',
-		'fr' => 'Français'
-	);
+	Minz_Translate::init();
+	$available_languages = Minz_Translate::availableLanguages();
 
 	if (!isset($_SESSION['language'])) {
-		$best = get_best_language();
-		if (!isset($available_languages[$best])) {
-			$best = 'en';
-		}
+		$_SESSION['language'] = get_best_language();
+	}
 
-		$_SESSION['language'] = $best;
+	if (!in_array($_SESSION['language'], $available_languages)) {
+		$_SESSION['language'] = 'en';
 	}
 
-	Minz_Translate::init(array(
-		'en' => 'English',
-		'fr' => 'Français',
-	), $_SESSION['language']);
+	Minz_Translate::reset($_SESSION['language']);
 }
 
 function get_best_language() {
@@ -257,7 +251,7 @@ function checkStep() {
 function checkStep0() {
 	$languages = Minz_Translate::availableLanguages();
 	$language = isset($_SESSION['language']) &&
-	            isset($languages[$_SESSION['language']]);
+	            in_array($_SESSION['language'], $languages);
 
 	return array(
 		'language' => $language ? 'ok' : 'ko',
@@ -432,8 +426,10 @@ function printStep0() {
 			<label class="group-name" for="language"><?php echo _t('install.language'); ?></label>
 			<div class="group-controls">
 				<select name="language" id="language">
-				<?php foreach ($languages as $short => $lib) { ?>
-				<option value="<?php echo $short; ?>"<?php echo $actual == $short ? ' selected="selected"' : ''; ?>><?php echo $lib; ?></option>
+				<?php foreach ($languages as $lang) { ?>
+				<option value="<?php echo $lang; ?>"<?php echo $actual == $lang ? ' selected="selected"' : ''; ?>>
+					<?php echo _t('gen.lang.' . $lang); ?>
+				</option>
 				<?php } ?>
 				</select>
 			</div>

+ 4 - 0
app/layout/aside_configure.phtml

@@ -22,6 +22,10 @@
 	                          Minz_Request::actionName() === 'profile'? ' active' : ''; ?>">
 		<a href="<?php echo _url('user', 'profile'); ?>"><?php echo _t('gen.menu.user_profile'); ?></a>
 	</li>
+	<li class="item<?php echo Minz_Request::controllerName() === 'extension' &&
+	                          Minz_Request::actionName() === 'index'? ' active' : ''; ?>">
+		<a href="<?php echo _url('extension', 'index'); ?>"><?php echo _t('gen.menu.extensions'); ?></a>
+	</li>
 	<?php if (FreshRSS_Auth::hasAccess('admin')) { ?>
 	<li class="nav-header"><?php echo _t('gen.menu.admin'); ?></li>
 	<li class="item<?php echo Minz_Request::controllerName() === 'user' &&

+ 1 - 0
app/layout/header.phtml

@@ -64,6 +64,7 @@ if (FreshRSS_Auth::accessNeedsAction()) {
 				<li class="item"><a href="<?php echo _url('configure', 'shortcut'); ?>"><?php echo _t('gen.menu.shortcuts'); ?></a></li>
 				<li class="item"><a href="<?php echo _url('configure', 'queries'); ?>"><?php echo _t('gen.menu.queries'); ?></a></li>
 				<li class="item"><a href="<?php echo _url('user', 'profile'); ?>"><?php echo _t('gen.menu.user_profile'); ?></a></li>
+				<li class="item"><a href="<?php echo _url('extension', 'index'); ?>"><?php echo _t('gen.menu.extensions'); ?></a></li>
 				<?php if (FreshRSS_Auth::hasAccess('admin')) { ?>
 				<li class="separator"></li>
 				<li class="dropdown-header"><?php echo _t('gen.menu.admin'); ?></li>

+ 2 - 2
app/views/configure/display.phtml

@@ -11,8 +11,8 @@
 			<div class="group-controls">
 				<select name="language" id="language">
 				<?php $languages = Minz_Translate::availableLanguages(); ?>
-				<?php foreach ($languages as $short => $lib) { ?>
-				<option value="<?php echo $short; ?>"<?php echo FreshRSS_Context::$user_conf->language === $short ? ' selected="selected"' : ''; ?>><?php echo $lib; ?></option>
+				<?php foreach ($languages as $lang) { ?>
+				<option value="<?php echo $lang; ?>"<?php echo FreshRSS_Context::$user_conf->language === $lang ? ' selected="selected"' : ''; ?>><?php echo _t('gen.lang.' . $lang); ?></option>
 				<?php } ?>
 				</select>
 			</div>

+ 23 - 0
app/views/extension/configure.phtml

@@ -0,0 +1,23 @@
+<?php
+
+if (!Minz_Request::param('ajax')) {
+	$this->partial('aside_configure');
+}
+
+?>
+
+<div class="post">
+	<h1><?php echo $this->extension->getName(); ?> (<?php echo $this->extension->getVersion(); ?>) — <?php echo $this->extension->getType(); ?></h1>
+
+	<p class="alert alert-warn"><?php echo $this->extension->getDescription(); ?> — <?php echo _t('gen.short.by_author', $this->extension->getAuthor()); ?></p>
+
+	<h2><?php echo _t('gen.action.manage'); ?></h2>
+	<?php
+		$configure_view = $this->extension->getConfigureView();
+		if ($configure_view !== false) {
+			echo $configure_view;
+		} else {
+	?>
+	<p class="alert alert-warn"><?php echo _t('admin.extensions.no_configure_view'); ?></p>
+	<?php } ?>
+</div>

+ 39 - 0
app/views/extension/index.phtml

@@ -0,0 +1,39 @@
+<?php $this->partial('aside_configure'); ?>
+
+<div class="post">
+	<a href="<?php echo _url('index', 'index'); ?>"><?php echo _t('gen.action.back_to_rss_feeds'); ?></a>
+
+	<h1><?php echo _t('admin.extensions.title'); ?></h1>
+
+	<?php if (!empty($this->extension_list)) { ?>
+	<form id="form-extension" method="post" style="display: none"></form>
+	<?php foreach ($this->extension_list as $ext) { ?>
+	<ul class="horizontal-list">
+		<li class="item">
+			<?php if ($ext->getType() === 'user' || FreshRSS_Auth::hasAccess('admin')) { ?>
+			<?php $name_encoded = urlencode($ext->getName()); ?>
+			<div class="stick">
+				<a class="btn open-slider" href="<?php echo _url('extension', 'configure', 'e', $name_encoded); ?>"><?php echo _i('configure'); ?> <?php echo _t('gen.action.manage'); ?></a>
+				<?php if ($ext->is_enabled()) { ?>
+				<button class="btn active" form="form-extension" formaction="<?php echo _url('extension', 'disable', 'e', $name_encoded); ?>"><?php echo _t('gen.action.disable'); ?></button>
+				<?php } else { ?>
+				<button class="btn" form="form-extension" formaction="<?php echo _url('extension', 'enable', 'e', $name_encoded); ?>"><?php echo _t('gen.action.enable'); ?></button>
+				<?php } ?>
+				<?php if (FreshRSS_Auth::hasAccess('admin')) { ?>
+				<button class="btn btn-attention confirm" form="form-extension" formaction="<?php echo _url('extension', 'remove', 'e', $name_encoded); ?>"><?php echo _t('gen.action.remove'); ?></button>
+				<?php } ?>
+			</div>
+			<?php } else { ?>
+			<?php echo _t('admin.extensions.system'); ?>
+			<?php } ?>
+		</li>
+		<li class="item"><?php echo $ext->getName(); ?></li>
+	</ul>
+	<?php } ?>
+	<?php } else { ?>
+	<p class="alert alert-warn"><?php echo _t('admin.extensions.empty_list'); ?></p>
+	<?php } ?>
+</div>
+
+<a href="#" id="close-slider"></a>
+<div id="slider"></div>

+ 86 - 0
app/views/helpers/index/normal/entry_bottom.phtml

@@ -0,0 +1,86 @@
+<?php
+	$sharing = array();
+	if (FreshRSS_Auth::hasAccess()) {
+		$sharing = FreshRSS_Context::$user_conf->sharing;
+	}
+
+	$bottomline_read = FreshRSS_Context::$user_conf->bottomline_read;
+	$bottomline_favorite = FreshRSS_Context::$user_conf->bottomline_favorite;
+	$bottomline_sharing = FreshRSS_Context::$user_conf->bottomline_sharing && (count($sharing) > 0);
+	$bottomline_tags = FreshRSS_Context::$user_conf->bottomline_tags;
+	$bottomline_date = FreshRSS_Context::$user_conf->bottomline_date;
+	$bottomline_link = FreshRSS_Context::$user_conf->bottomline_link;
+?><ul class="horizontal-list bottom"><?php
+	if (FreshRSS_Auth::hasAccess()) {
+		if ($bottomline_read) {
+			?><li class="item manage"><?php
+				$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $this->entry->id()));
+				if ($this->entry->isRead()) {
+					$arUrl['params']['is_read'] = 0;
+				}
+				?><a class="read" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
+					echo _i($this->entry->isRead() ? 'read' : 'unread'); ?></a><?php
+			?></li><?php
+		}
+		if ($bottomline_favorite) {
+			?><li class="item manage"><?php
+				$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $this->entry->id()));
+				if ($this->entry->isFavorite()) {
+					$arUrl['params']['is_favorite'] = 0;
+				}
+				?><a class="bookmark" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
+					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
+			?></li><?php
+		}
+	} ?>
+	<li class="item"><?php
+			if ($bottomline_sharing) {
+		?><div class="dropdown">
+			<div id="dropdown-share-<?php echo $item->id();?>" class="dropdown-target"></div>
+			<a class="dropdown-toggle" href="#dropdown-share-<?php echo $item->id();?>">
+				<?php echo _i('share'); ?>
+				<?php echo _t('index.share'); ?>
+			</a>
+
+			<ul class="dropdown-menu">
+				<li class="dropdown-close"><a href="#close">❌</a></li><?php
+					$link = $item->link();
+					$title = $item->title() . ' · ' . $feed->name();
+					foreach (FreshRSS_Context::$user_conf->sharing as $share_options) {
+						$share = FreshRSS_Share::get($share_options['type']);
+						$share_options['link'] = $link;
+						$share_options['title'] = $title;
+						$share->update($share_options);
+				?><li class="item share">
+					<a target="_blank" href="<?php echo $share->url(); ?>"><?php echo $share->name(); ?></a>
+				</li><?php
+					}
+			?></ul>
+		</div>
+		<?php } ?>
+	</li><?php
+	$tags = $bottomline_tags ? $this->entry->tags() : null;
+	if (!empty($tags)) {
+	?><li class="item">
+		<div class="dropdown">
+			<div id="dropdown-tags-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
+			<?php echo _i('tag'); ?>
+			<a class="dropdown-toggle" href="#dropdown-tags-<?php echo $this->entry->id();?>"><?php
+				echo _t('index.tag.related');
+			?></a>
+			<ul class="dropdown-menu">
+				<li class="dropdown-close"><a href="#close">❌</a></li><?php
+				foreach($tags as $tag) {
+					?><li class="item"><a href="<?php echo _url('index', 'index', 'search', urlencode('#' . $tag)); ?>"><?php echo $tag; ?></a></li><?php
+				} ?>
+			</ul>
+		</div>
+	</li><?php
+	}
+	if ($bottomline_date) {
+		?><li class="item date"><?php echo $this->entry->date(); ?></li><?php
+	}
+	if ($bottomline_link) {
+		?><li class="item link"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php
+	} ?>
+</ul>

+ 33 - 0
app/views/helpers/index/normal/entry_header.phtml

@@ -0,0 +1,33 @@
+<?php
+	$topline_read = FreshRSS_Context::$user_conf->topline_read;
+	$topline_favorite = FreshRSS_Context::$user_conf->topline_favorite;
+	$topline_date = FreshRSS_Context::$user_conf->topline_date;
+	$topline_link = FreshRSS_Context::$user_conf->topline_link;
+?><ul class="horizontal-list flux_header"><?php
+	if (FreshRSS_Auth::hasAccess()) {
+		if ($topline_read) {
+			?><li class="item manage"><?php
+				$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $this->entry->id()));
+				if ($this->entry->isRead()) {
+					$arUrl['params']['is_read'] = 0;
+				}
+				?><a class="read" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
+					echo _i($this->entry->isRead() ? 'read' : 'unread'); ?></a><?php
+			?></li><?php
+		}
+		if ($topline_favorite) {
+			?><li class="item manage"><?php
+				$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $this->entry->id()));
+				if ($this->entry->isFavorite()) {
+					$arUrl['params']['is_favorite'] = 0;
+				}
+				?><a class="bookmark" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
+					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
+			?></li><?php
+		}
+	}
+	?><li class="item website"><a href="<?php echo _url('index', 'index', 'get', 'f_' . $this->feed->id()); ?>"><img class="favicon" src="<?php echo $this->feed->favicon(); ?>" alt="✇" /> <span><?php echo $this->feed->name(); ?></span></a></li>
+	<li class="item title"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></li>
+	<?php if ($topline_date) { ?><li class="item date"><?php echo $this->entry->date(); ?> </li><?php } ?>
+	<?php if ($topline_link) { ?><li class="item link"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php } ?>
+</ul>

+ 0 - 23
app/views/index/index.phtml

@@ -1,23 +0,0 @@
-<?php
-
-$output = Minz_Request::param('output', 'normal');
-
-if (FreshRSS_Auth::hasAccess() || FreshRSS_Context::$system_conf->allow_anonymous) {
-	if ($output === 'normal') {
-		$this->renderHelper('view/normal_view');
-	} elseif ($output === 'reader') {
-		$this->renderHelper('view/reader_view');
-	} elseif ($output === 'rss') {
-		$this->renderHelper('view/rss_view');
-	} else {
-		Minz_Request::_param('output', 'normal');
-		$output = 'normal';
-		$this->renderHelper('view/normal_view');
-	}
-} elseif ($output === 'rss') {
-	// token has already been checked in the controller so we can show the view
-	$this->renderHelper('view/rss_view');
-} else {
-	// Normally, it should not happen, but log it anyway
-	Minz_Log::error('Something is wrong in ' . __FILE__ . ' line ' . __LINE__);
-}

+ 32 - 138
app/views/index/normal.phtml

@@ -7,24 +7,8 @@ if (!empty($this->entries)) {
 	$display_today = true;
 	$display_yesterday = true;
 	$display_others = true;
-	if (FreshRSS_Auth::hasAccess()) {
-		$sharing = FreshRSS_Context::$user_conf->sharing;
-	} else {
-		$sharing = array();
-	}
 	$hidePosts = !FreshRSS_Context::$user_conf->display_posts;
 	$lazyload = FreshRSS_Context::$user_conf->lazyload;
-	$topline_read = FreshRSS_Context::$user_conf->topline_read;
-	$topline_favorite = FreshRSS_Context::$user_conf->topline_favorite;
-	$topline_date = FreshRSS_Context::$user_conf->topline_date;
-	$topline_link = FreshRSS_Context::$user_conf->topline_link;
-	$bottomline_read = FreshRSS_Context::$user_conf->bottomline_read;
-	$bottomline_favorite = FreshRSS_Context::$user_conf->bottomline_favorite;
-	$bottomline_sharing = FreshRSS_Context::$user_conf->bottomline_sharing && (count($sharing));
-	$bottomline_tags = FreshRSS_Context::$user_conf->bottomline_tags;
-	$bottomline_date = FreshRSS_Context::$user_conf->bottomline_date;
-	$bottomline_link = FreshRSS_Context::$user_conf->bottomline_link;
-
 	$content_width = FreshRSS_Context::$user_conf->content_width;
 
 	$today = @strtotime('today');
@@ -35,7 +19,21 @@ if (!empty($this->entries)) {
 		<a href="<?php echo Minz_Url::display(Minz_Request::currentRequest()); ?>"><?php echo _t('gen.js.new_article'); /* TODO: move string in JS*/ ?></a>
 	</div><?php
 	foreach ($this->entries as $item) {
-		if ($display_today && $item->isDay(FreshRSS_Days::TODAY, $today)) {
+		$this->entry = Minz_ExtensionManager::callHook('entry_before_display', $item);
+		if (is_null($this->entry)) {
+			continue;
+		}
+
+		// We most likely already have the feed object in cache
+		$this->feed = FreshRSS_CategoryDAO::findFeed($this->categories, $this->entry->feed());
+		if ($this->feed == null) {
+			$this->feed = $this->entry->feed(true);
+			if ($this->feed == null) {
+				$this->feed = FreshRSS_Feed::example();
+			}
+		}
+
+		if ($display_today && $this->entry->isDay(FreshRSS_Days::TODAY, $today)) {
 			?><div class="day" id="day_today"><?php
 				echo _t('gen.date.today');
 				?><span class="date"> — <?php echo timestamptodate(time(), false); ?></span><?php
@@ -43,7 +41,7 @@ if (!empty($this->entries)) {
 			?></div><?php
 			$display_today = false;
 		}
-		if ($display_yesterday && $item->isDay(FreshRSS_Days::YESTERDAY, $today)) {
+		if ($display_yesterday && $this->entry->isDay(FreshRSS_Days::YESTERDAY, $today)) {
 			?><div class="day" id="day_yesterday"><?php
 				echo _t('gen.date.yesterday');
 				?><span class="date"> — <?php echo timestamptodate(time() - 86400, false); ?></span><?php
@@ -51,139 +49,35 @@ if (!empty($this->entries)) {
 			?></div><?php
 			$display_yesterday = false;
 		}
-		if ($display_others && $item->isDay(FreshRSS_Days::BEFORE_YESTERDAY, $today)) {
+		if ($display_others && $this->entry->isDay(FreshRSS_Days::BEFORE_YESTERDAY, $today)) {
 			?><div class="day" id="day_before_yesterday"><?php
 				echo _t('gen.date.before_yesterday');
 				?><span class="name"><?php echo FreshRSS_Context::$name; ?></span><?php
 			?></div><?php
 			$display_others = false;
 		}
-	?><div class="flux<?php echo !$item->isRead() ? ' not_read' : ''; ?><?php echo $item->isFavorite() ? ' favorite' : ''; ?>" id="flux_<?php echo $item->id(); ?>">
-		<ul class="horizontal-list flux_header"><?php
-			if (FreshRSS_Auth::hasAccess()) {
-				if ($topline_read) {
-					?><li class="item manage"><?php
-						$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $item->id()));
-						if ($item->isRead()) {
-							$arUrl['params']['is_read'] = 0;
-						}
-						?><a class="read" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
-							echo _i($item->isRead() ? 'read' : 'unread'); ?></a><?php
-					?></li><?php
-				}
-				if ($topline_favorite) {
-					?><li class="item manage"><?php
-						$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $item->id()));
-						if ($item->isFavorite()) {
-							$arUrl['params']['is_favorite'] = 0;
-						}
-						?><a class="bookmark" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
-							echo _i($item->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
-					?></li><?php
-				}
-			}
-			$feed = FreshRSS_CategoryDAO::findFeed($this->categories, $item->feed());	//We most likely already have the feed object in cache
-			if ($feed == null) {
-				$feed = $item->feed(true);
-				if ($feed == null) {
-					$feed = FreshRSS_Feed::example();
-				}
-			}
-			?><li class="item website"><a href="<?php echo _url('index', 'index', 'get', 'f_' . $feed->id()); ?>"><img class="favicon" src="<?php echo $feed->favicon(); ?>" alt="✇" /> <span><?php echo $feed->name(); ?></span></a></li>
-			<li class="item title"><a target="_blank" href="<?php echo $item->link(); ?>"><?php echo $item->title(); ?></a></li>
-			<?php if ($topline_date) { ?><li class="item date"><?php echo $item->date(); ?> </li><?php } ?>
-			<?php if ($topline_link) { ?><li class="item link"><a target="_blank" href="<?php echo $item->link(); ?>"><?php echo _i('link'); ?></a></li><?php } ?>
-		</ul>
+	?><div class="flux<?php echo !$this->entry->isRead() ? ' not_read' : ''; ?><?php echo $this->entry->isFavorite() ? ' favorite' : ''; ?>" id="flux_<?php echo $this->entry->id(); ?>"><?php
 
-		<div class="flux_content">
+			$this->renderHelper('index/normal/entry_header');
+
+		?><div class="flux_content">
 			<div class="content <?php echo $content_width; ?>">
-				<h1 class="title"><a target="_blank" href="<?php echo $item->link(); ?>"><?php echo $item->title(); ?></a></h1>
+				<h1 class="title"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></h1>
 				<?php
-					$author = $item->author();
+					$author = $this->entry->author();
 					echo $author != '' ? '<div class="author">' . _t('gen.short.by_author', $author) . '</div>' : '',
-						$lazyload && $hidePosts ? lazyimg($item->content()) : $item->content();
+						$lazyload && $hidePosts ? lazyimg($this->entry->content()) : $this->entry->content();
 				?>
-			</div>
-			<ul class="horizontal-list bottom"><?php
-				if (FreshRSS_Auth::hasAccess()) {
-					if ($bottomline_read) {
-						?><li class="item manage"><?php
-							$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $item->id()));
-							if ($item->isRead()) {
-								$arUrl['params']['is_read'] = 0;
-							}
-							?><a class="read" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
-								echo _i($item->isRead() ? 'read' : 'unread'); ?></a><?php
-						?></li><?php
-					}
-					if ($bottomline_favorite) {
-						?><li class="item manage"><?php
-							$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $item->id()));
-							if ($item->isFavorite()) {
-								$arUrl['params']['is_favorite'] = 0;
-							}
-							?><a class="bookmark" href="<?php echo Minz_Url::display($arUrl); ?>"><?php
-								echo _i($item->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
-						?></li><?php
-					}
-				} ?>
-				<li class="item"><?php
-						if ($bottomline_sharing) {
-					?><div class="dropdown">
-						<div id="dropdown-share-<?php echo $item->id();?>" class="dropdown-target"></div>
-						<a class="dropdown-toggle" href="#dropdown-share-<?php echo $item->id();?>">
-							<?php echo _i('share'); ?>
-							<?php echo _t('index.share'); ?>
-						</a>
+			</div><?php
 
-						<ul class="dropdown-menu">
-							<li class="dropdown-close"><a href="#close">❌</a></li><?php
-								$link = $item->link();
-								$title = $item->title() . ' · ' . $feed->name();
-								foreach (FreshRSS_Context::$user_conf->sharing as $share_options) {
-									$share = FreshRSS_Share::get($share_options['type']);
-									$share_options['link'] = $link;
-									$share_options['title'] = $title;
-									$share->update($share_options);
-							?><li class="item share">
-								<a target="_blank" href="<?php echo $share->url(); ?>"><?php echo $share->name(); ?></a>
-							</li><?php
-								}
-						?></ul>
-					</div>
-					<?php } ?>
-				</li><?php
-				$tags = $bottomline_tags ? $item->tags() : null;
-				if (!empty($tags)) {
-				?><li class="item">
-					<div class="dropdown">
-						<div id="dropdown-tags-<?php echo $item->id();?>" class="dropdown-target"></div>
-						<?php echo _i('tag'); ?>
-						<a class="dropdown-toggle" href="#dropdown-tags-<?php echo $item->id();?>"><?php
-							echo _t('index.tag.related');
-						?></a>
-						<ul class="dropdown-menu">
-							<li class="dropdown-close"><a href="#close">❌</a></li><?php
-							foreach($tags as $tag) {
-								?><li class="item"><a href="<?php echo _url('index', 'index', 'search', urlencode('#' . $tag)); ?>"><?php echo $tag; ?></a></li><?php
-							} ?>
-						</ul>
-					</div>
-				</li><?php
-				}
-				if ($bottomline_date) {
-					?><li class="item date"><?php echo $item->date(); ?></li><?php
-				}
-				if ($bottomline_link) {
-					?><li class="item link"><a target="_blank" href="<?php echo $item->link(); ?>"><?php echo _i('link'); ?></a></li><?php
-				} ?>
-			</ul>
-		</div>
-	</div>
-	<?php } ?>
+			$this->renderHelper('index/normal/entry_bottom');
 
-	<?php $this->renderHelper('pagination'); ?>
-</div>
+		?></div>
+	</div><?php
+	}
+
+	$this->renderHelper('pagination');
+?></div>
 
 <?php $this->partial('nav_entries'); ?>
 

+ 7 - 4
app/views/index/reader.phtml

@@ -6,10 +6,13 @@ if (!empty($this->entries)) {
 	$content_width = FreshRSS_Context::$user_conf->content_width;
 ?>
 
-<div id="stream" class="reader">
-	<?php foreach ($this->entries as $item) { ?>
-
-	<div class="flux<?php echo !$item->isRead() ? ' not_read' : ''; ?><?php echo $item->isFavorite() ? ' favorite' : ''; ?>" id="flux_<?php echo $item->id(); ?>">
+<div id="stream" class="reader"><?php
+	foreach ($this->entries as $item) {
+		$item = Minz_ExtensionManager::callHook('entry_before_display', $item);
+		if (is_null($item)) {
+			continue;
+		}
+	?><div class="flux<?php echo !$item->isRead() ? ' not_read' : ''; ?><?php echo $item->isFavorite() ? ' favorite' : ''; ?>" id="flux_<?php echo $item->id(); ?>">
 		<div class="flux_content">
 			<div class="content <?php echo $content_width; ?>">
 				<?php

+ 2 - 2
app/views/user/manage.phtml

@@ -11,8 +11,8 @@
 			<div class="group-controls">
 				<select name="new_user_language" id="new_user_language">
 				<?php $languages = Minz_Translate::availableLanguages(); ?>
-				<?php foreach ($languages as $short => $lib) { ?>
-				<option value="<?php echo $short; ?>"<?php echo FreshRSS_Context::$user_conf->language === $short ? ' selected="selected"' : ''; ?>><?php echo $lib; ?></option>
+				<?php foreach ($languages as $lang) { ?>
+				<option value="<?php echo $lang; ?>"<?php echo FreshRSS_Context::$user_conf->language === $lang ? ' selected="selected"' : ''; ?>><?php echo _t('gen.lang.' . $lang); ?></option>
 				<?php } ?>
 				</select>
 			</div>

+ 2 - 1
constants.php

@@ -20,6 +20,7 @@ define('FRESHRSS_PATH', dirname(__FILE__));
 		define('CACHE_PATH', DATA_PATH . '/cache');
 
 	define('LIB_PATH', FRESHRSS_PATH . '/lib');
-		define('APP_PATH', FRESHRSS_PATH . '/app');
+	define('APP_PATH', FRESHRSS_PATH . '/app');
+	define('EXTENSIONS_PATH', FRESHRSS_PATH . '/extensions');
 
 define('TMP_PATH', sys_get_temp_dir());

+ 1 - 0
data/config.default.php

@@ -27,4 +27,5 @@ return array(
 		'base' => '',
 		'prefix' => '',
 	),
+	'extensions_enabled' => array(),
 );

+ 1 - 0
data/users/_/config.default.php

@@ -63,4 +63,5 @@ return array (
 	'queries' => array (
 	),
 	'html5_notif_timeout' => 0,
+	'extensions_enabled' => array(),
 );

+ 1 - 1
extensions/.gitignore

@@ -1 +1 @@
-/[xX]
+[xX]*

+ 0 - 0
extensions/Read-me.txt → extensions/README.md


+ 13 - 0
lib/Minz/Configuration.php

@@ -101,6 +101,19 @@ class Minz_Configuration {
 	 */
 	private $configuration_setter = null;
 
+	public function removeExtension($ext_name) {
+		self::$extensions_enabled = array_diff(
+			self::$extensions_enabled,
+			array($ext_name)
+		);
+	}
+	public function addExtension($ext_name) {
+		$found = array_search($ext_name, self::$extensions_enabled) !== false;
+		if (!$found) {
+			self::$extensions_enabled[] = $ext_name;
+		}
+	}
+
 	/**
 	 * Create a new Minz_Configuration object.
 	 * 

+ 48 - 5
lib/Minz/Dispatcher.php

@@ -15,6 +15,7 @@ class Minz_Dispatcher {
 	/* singleton */
 	private static $instance = null;
 	private static $needsReset;
+	private static $registrations = array();
 
 	private $controller;
 
@@ -38,7 +39,7 @@ class Minz_Dispatcher {
 			self::$needsReset = false;
 
 			try {
-				$this->createController ('FreshRSS_' . Minz_Request::controllerName () . '_Controller');
+				$this->createController (Minz_Request::controllerName ());
 				$this->controller->init ();
 				$this->controller->firstAction ();
 				if (!self::$needsReset) {
@@ -67,14 +68,18 @@ class Minz_Dispatcher {
 
 	/**
 	 * Instancie le Controller
-	 * @param $controller_name le nom du controller à instancier
+	 * @param $base_name le nom du controller à instancier
 	 * @exception ControllerNotExistException le controller n'existe pas
 	 * @exception ControllerNotActionControllerException controller n'est
 	 *          > pas une instance de ActionController
 	 */
-	private function createController ($controller_name) {
-		$filename = APP_PATH . self::CONTROLLERS_PATH_NAME . '/'
-		          . $controller_name . '.php';
+	private function createController ($base_name) {
+		if (self::isRegistered($base_name)) {
+			self::loadController($base_name);
+			$controller_name = 'FreshExtension_' . $base_name . '_Controller';
+		} else {
+			$controller_name = 'FreshRSS_' . $base_name . '_Controller';
+		}
 
 		if (!class_exists ($controller_name)) {
 			throw new Minz_ControllerNotExistException (
@@ -114,4 +119,42 @@ class Minz_Dispatcher {
 			$action_name
 		));
 	}
+
+	/**
+	 * Register a controller file.
+	 *
+	 * @param $base_name the base name of the controller (i.e. ./?c=<base_name>)
+	 * @param $base_path the base path where we should look into to find info.
+	 */
+	public static function registerController($base_name, $base_path) {
+		if (!self::isRegistered($base_name)) {
+			self::$registrations[$base_name] = $base_path;
+		}
+	}
+
+	/**
+	 * Return if a controller is registered.
+	 *
+	 * @param $base_name the base name of the controller.
+	 * @return true if the controller has been registered, false else.
+	 */
+	public static function isRegistered($base_name) {
+		return isset(self::$registrations[$base_name]);
+	}
+
+	/**
+	 * Load a controller file (include).
+	 *
+	 * @param $base_name the base name of the controller.
+	 */
+	private static function loadController($base_name) {
+		$base_path = self::$registrations[$base_name];
+		$controller_filename = $base_path . '/controllers/' . $base_name . 'Controller.php';
+		include_once $controller_filename;
+	}
+
+	private static function setViewPath($controller, $base_name) {
+		$base_path = self::$registrations[$base_name];
+		$controller->view()->setBasePathname($base_path);
+	}
 }

+ 198 - 0
lib/Minz/Extension.php

@@ -0,0 +1,198 @@
+<?php
+
+/**
+ * The extension base class.
+ */
+class Minz_Extension {
+	private $name;
+	private $entrypoint;
+	private $path;
+	private $author;
+	private $description;
+	private $version;
+	private $type;
+
+	public static $authorized_types = array(
+		'system',
+		'user',
+	);
+
+	private $is_enabled;
+
+	/**
+	 * The constructor to assign specific information to the extension.
+	 *
+	 * Available fields are:
+	 * - name: the name of the extension (required).
+	 * - entrypoint: the extension class name (required).
+	 * - path: the pathname to the extension files (required).
+	 * - author: the name and / or email address of the extension author.
+	 * - description: a short description to describe the extension role.
+	 * - version: a version for the current extension.
+	 * - type: "system" or "user" (default).
+	 *
+	 * It must not be redefined by child classes.
+	 *
+	 * @param $meta_info contains information about the extension.
+	 */
+	public function __construct($meta_info) {
+		$this->name = $meta_info['name'];
+		$this->entrypoint = $meta_info['entrypoint'];
+		$this->path = $meta_info['path'];
+		$this->author = isset($meta_info['author']) ? $meta_info['author'] : '';
+		$this->description = isset($meta_info['description']) ? $meta_info['description'] : '';
+		$this->version = isset($meta_info['version']) ? $meta_info['version'] : '0.1';
+		$this->setType(isset($meta_info['type']) ? $meta_info['type'] : 'user');
+
+		$this->is_enabled = false;
+	}
+
+	/**
+	 * Used when installing an extension (e.g. update the database scheme).
+	 *
+	 * It must be redefined by child classes.
+	 */
+	public function install() {}
+
+	/**
+	 * Used when uninstalling an extension (e.g. revert the database scheme to
+	 * cancel changes from install).
+	 *
+	 * It must be redefined by child classes.
+	 */
+	public function uninstall() {}
+
+	/**
+	 * Call at the initialization of the extension (i.e. when the extension is
+	 * enabled by the extension manager).
+	 *
+	 * It must be redefined by child classes.
+	 */
+	public function init() {}
+
+	/**
+	 * Set the current extension to enable.
+	 */
+	public function enable() {
+		$this->is_enabled = true;
+	}
+
+	/**
+	 * Return if the extension is currently enabled.
+	 *
+	 * @return true if extension is enabled, false else.
+	 */
+	public function is_enabled() {
+		return $this->is_enabled;
+	}
+
+	/**
+	 * Return the content of the configure view for the current extension.
+	 *
+	 * @return the html content from ext_dir/configure.phtml, false if it does
+	 *         not exist.
+	 */
+	public function getConfigureView() {
+		$filename = $this->path . '/configure.phtml';
+		if (!file_exists($filename)) {
+			return false;
+		}
+
+		ob_start();
+		include($filename);
+		return ob_get_clean();
+	}
+
+	/**
+	 * Handle the configure action.
+	 *
+	 * It must be redefined by child classes.
+	 */
+	public function handleConfigureAction() {}
+
+	/**
+	 * Getters and setters.
+	 */
+	public function getName() {
+		return $this->name;
+	}
+	public function getEntrypoint() {
+		return $this->entrypoint;
+	}
+	public function getPath() {
+		return $this->path;
+	}
+	public function getAuthor() {
+		return $this->author;
+	}
+	public function getDescription() {
+		return $this->description;
+	}
+	public function getVersion() {
+		return $this->version;
+	}
+	public function getType() {
+		return $this->type;
+	}
+	private function setType($type) {
+		if (!in_array($type, self::$authorized_types)) {
+			throw new Minz_ExtensionException('invalid `type` info', $this->name);
+		}
+		$this->type = $type;
+	}
+
+	/**
+	 * Return the url for a given file.
+	 *
+	 * @param $filename name of the file to serve.
+	 * @param $type the type (js or css) of the file to serve.
+	 * @return the url corresponding to the file.
+	 */
+	public function getFileUrl($filename, $type) {
+		$dir = end(explode('/', $this->path));
+		$file_name_url = urlencode($dir . '/static/' . $filename);
+
+		$absolute_path = $this->path . '/static/' . $filename;
+		$mtime = @filemtime($absolute_path);
+
+		$url = '/ext.php?f=' . $file_name_url .
+		       '&amp;t=' . $type .
+		       '&amp;' . $mtime;
+		return Minz_Url::display($url);
+	}
+
+	/**
+	 * Register a controller in the Dispatcher.
+	 *
+	 * @param @base_name the base name of the controller. Final name will be:
+	 *                   FreshExtension_<base_name>_Controller.
+	 */
+	public function registerController($base_name) {
+		Minz_Dispatcher::registerController($base_name, $this->path);
+	}
+
+	/**
+	 * Register the views in order to be accessible by the application.
+	 */
+	public function registerViews() {
+		Minz_View::addBasePathname($this->path);
+	}
+
+	/**
+	 * Register i18n files from ext_dir/i18n/
+	 */
+	public function registerTranslates() {
+		$i18n_dir = $this->path . '/i18n';
+		Minz_Translate::registerPath($i18n_dir);
+	}
+
+	/**
+	 * Register a new hook.
+	 *
+	 * @param $hook_name the hook name (must exist).
+	 * @param $hook_function the function name to call (must be callable).
+	 */
+	public function registerHook($hook_name, $hook_function) {
+		Minz_ExtensionManager::addHook($hook_name, $hook_function, $this);
+	}
+}

+ 15 - 0
lib/Minz/ExtensionException.php

@@ -0,0 +1,15 @@
+<?php
+
+class Minz_ExtensionException extends Minz_Exception {
+	public function __construct ($message, $extension_name = false, $code = self::ERROR) {
+		if ($extension_name) {
+			$message = 'An error occured in `' . $extension_name
+			         . '` extension with the message: ' . $message;
+		} else {
+			$message = 'An error occured in an unnamed '
+			         . 'extension with the message: ' . $message;
+		}
+
+		parent::__construct($message, $code);
+	}
+}

+ 249 - 0
lib/Minz/ExtensionManager.php

@@ -0,0 +1,249 @@
+<?php
+
+/**
+ * An extension manager to load extensions present in EXTENSIONS_PATH.
+ *
+ * @todo see coding style for methods!!
+ */
+class Minz_ExtensionManager {
+	private static $ext_metaname = 'metadata.json';
+	private static $ext_entry_point = 'extension.php';
+	private static $ext_list = array();
+	private static $ext_list_enabled = array();
+
+	private static $ext_auto_enabled = array();
+
+	// List of available hooks. Please keep this list sorted.
+	private static $hook_list = array(
+		'entry_before_display' => array(),  // function($entry) -> Entry | null
+		'entry_before_insert' => array(),  // function($entry) -> Entry | null
+		'feed_before_insert' => array(),  // function($feed) -> Feed | null
+	);
+	private static $ext_to_hooks = array();
+
+	/**
+	 * Initialize the extension manager by loading extensions in EXTENSIONS_PATH.
+	 *
+	 * A valid extension is a directory containing metadata.json and
+	 * extension.php files.
+	 * metadata.json is a JSON structure where the only required fields are
+	 * `name` and `entry_point`.
+	 * extension.php should contain at least a class named <name>Extension where
+	 * <name> must match with the entry point in metadata.json. This class must
+	 * inherit from Minz_Extension class.
+	 */
+	public static function init() {
+		$list_potential_extensions = array_values(array_diff(
+			scandir(EXTENSIONS_PATH),
+			array('..', '.')
+		));
+
+		$system_conf = Minz_Configuration::get('system');
+		self::$ext_auto_enabled = $system_conf->extensions_enabled;
+
+		foreach ($list_potential_extensions as $ext_dir) {
+			$ext_pathname = EXTENSIONS_PATH . '/' . $ext_dir;
+			$metadata_filename = $ext_pathname . '/' . self::$ext_metaname;
+
+			// Try to load metadata file.
+			if (!file_exists($metadata_filename)) {
+				// No metadata file? Invalid!
+				continue;
+			}
+			$meta_raw_content = file_get_contents($metadata_filename);
+			$meta_json = json_decode($meta_raw_content, true);
+			if (!$meta_json || !self::is_valid_metadata($meta_json)) {
+				// metadata.json is not a json file? Invalid!
+				// or metadata.json is invalid (no required information), invalid!
+				Minz_Log::warning('`' . $metadata_filename . '` is not a valid metadata file');
+				continue;
+			}
+
+			$meta_json['path'] = $ext_pathname;
+
+			// Try to load extension itself
+			$extension = self::load($meta_json);
+			if (!is_null($extension)) {
+				self::register($extension);
+			}
+		}
+	}
+
+	/**
+	 * Indicates if the given parameter is a valid metadata array.
+	 *
+	 * Required fields are:
+	 * - `name`: the name of the extension
+	 * - `entry_point`: a class name to load the extension source code
+	 * If the extension class name is `TestExtension`, entry point will be `Test`.
+	 * `entry_point` must be composed of alphanumeric characters.
+	 *
+	 * @param $meta is an array of values.
+	 * @return true if the array is valid, false else.
+	 */
+	public static function is_valid_metadata($meta) {
+		return !(empty($meta['name']) ||
+		         empty($meta['entrypoint']) ||
+		         !ctype_alnum($meta['entrypoint']));
+	}
+
+	/**
+	 * Load the extension source code based on info metadata.
+	 *
+	 * @param $info an array containing information about extension.
+	 * @return an extension inheriting from Minz_Extension.
+	 */
+	public static function load($info) {
+		$entry_point_filename = $info['path'] . '/' . self::$ext_entry_point;
+		$ext_class_name = $info['entrypoint'] . 'Extension';
+
+		include($entry_point_filename);
+
+		// Test if the given extension class exists.
+		if (!class_exists($ext_class_name)) {
+			Minz_Log::warning('`' . $ext_class_name .
+			                  '` cannot be found in `' . $entry_point_filename . '`');
+			return null;
+		}
+
+		// Try to load the class.
+		$extension = null;
+		try {
+			$extension = new $ext_class_name($info);
+		} catch (Minz_ExtensionException $e) {
+			// We cannot load the extension? Invalid!
+			Minz_Log::warning('In `' . $metadata_filename . '`: ' . $e->getMessage());
+			return null;
+		}
+
+		// Test if class is correct.
+		if (!($extension instanceof Minz_Extension)) {
+			Minz_Log::warning('`' . $ext_class_name .
+			                  '` is not an instance of `Minz_Extension`');
+			return null;
+		}
+
+		return $extension;
+	}
+
+	/**
+	 * Add the extension to the list of the known extensions ($ext_list).
+	 *
+	 * If the extension is present in $ext_auto_enabled and if its type is "system",
+	 * it will be enabled in the same time.
+	 *
+	 * @param $ext a valid extension.
+	 */
+	public static function register($ext) {
+		$name = $ext->getName();
+		self::$ext_list[$name] = $ext;
+
+		if ($ext->getType() === 'system' &&
+				in_array($name, self::$ext_auto_enabled)) {
+			self::enable($ext->getName());
+		}
+
+		self::$ext_to_hooks[$name] = array();
+	}
+
+	/**
+	 * Enable an extension so it will be called when necessary.
+	 *
+	 * The extension init() method will be called.
+	 *
+	 * @param $ext_name is the name of a valid extension present in $ext_list.
+	 */
+	public static function enable($ext_name) {
+		if (isset(self::$ext_list[$ext_name])) {
+			$ext = self::$ext_list[$ext_name];
+			self::$ext_list_enabled[$ext_name] = $ext;
+			$ext->enable();
+			$ext->init();
+		}
+	}
+
+	/**
+	 * Enable a list of extensions.
+	 *
+	 * @param $ext_list the names of extensions we want to load.
+	 */
+	public static function enable_by_list($ext_list) {
+		foreach ($ext_list as $ext_name) {
+			self::enable($ext_name);
+		}
+	}
+
+	/**
+	 * Return a list of extensions.
+	 *
+	 * @param $only_enabled if true returns only the enabled extensions (false by default).
+	 * @return an array of extensions.
+	 */
+	public static function list_extensions($only_enabled = false) {
+		if ($only_enabled) {
+			return self::$ext_list_enabled;
+		} else {
+			return self::$ext_list;
+		}
+	}
+
+	/**
+	 * Return an extension by its name.
+	 *
+	 * @param $ext_name the name of the extension.
+	 * @return the corresponding extension or null if it doesn't exist.
+	 */
+	public static function find_extension($ext_name) {
+		if (!isset(self::$ext_list[$ext_name])) {
+			return null;
+		}
+
+		return self::$ext_list[$ext_name];
+	}
+
+	/**
+	 * Add a hook function to a given hook.
+	 *
+	 * The hook name must be a valid one. For the valid list, see self::$hook_list
+	 * array keys.
+	 *
+	 * @param $hook_name the hook name (must exist).
+	 * @param $hook_function the function name to call (must be callable).
+	 * @param $ext the extension which register the hook.
+	 */
+	public static function addHook($hook_name, $hook_function, $ext) {
+		if (isset(self::$hook_list[$hook_name]) && is_callable($hook_function)) {
+			self::$hook_list[$hook_name][] = $hook_function;
+			self::$ext_to_hooks[$ext->getName()][] = $hook_name;
+		}
+	}
+
+	/**
+	 * Call functions related to a given hook.
+	 *
+	 * The hook name must be a valid one. For the valid list, see self::$hook_list
+	 * array keys.
+	 *
+	 * @param $hook_name the hook to call.
+	 * @param additionnal parameters (for signature, please see self::$hook_list comments)
+	 * @todo hook functions will have different signatures. So the $res = func($args);
+	 *       $args = $res; will not work for all of them in the future. We must
+	 *       find a better way to call hooks.
+	 */
+	public static function callHook($hook_name) {
+		$args = func_get_args();
+		unset($args[0]);
+
+		$result = $args[1];
+		foreach (self::$hook_list[$hook_name] as $function) {
+			$result = call_user_func_array($function, $args);
+
+			if (is_null($result)) {
+				break;
+			}
+
+			$args = $result;
+		}
+		return $result;
+	}
+}

+ 108 - 19
lib/Minz/Translate.php

@@ -10,9 +10,9 @@
  */
 class Minz_Translate {
 	/**
-	 * $lang_list is the list of available languages.
+	 * $path_list is the list of registered base path to search translations.
 	 */
-	private static $lang_list = array();
+	private static $path_list = array();
 
 	/**
 	 * $lang_name is the name of the current language to use.
@@ -20,9 +20,9 @@ class Minz_Translate {
 	private static $lang_name;
 
 	/**
-	 * $lang_path is the pathname of i18n files (e.g. ./app/i18n/en/).
+	 * $lang_files is a list of registered i18n files.
 	 */
-	private static $lang_path;
+	private static $lang_files = array();
 
 	/**
 	 * $translates is a cache for i18n translation.
@@ -31,13 +31,16 @@ class Minz_Translate {
 
 	/**
 	 * Init the translation object.
-	 * @param $lang_list the list of available languages.
 	 * @param $lang_name the lang to show.
 	 */
-	public static function init($lang_list, $lang_name) {
-		self::$lang_list = $lang_list;
+	public static function init($lang_name = null) {
 		self::$lang_name = $lang_name;
-		self::$lang_path = APP_PATH . '/i18n/' . self::$lang_name . '/';
+		self::$lang_files = array();
+		self::$translates = array();
+		self::registerPath(APP_PATH . '/i18n');
+		foreach (self::$path_list as $path) {
+			self::loadLang($path);
+		}
 	}
 
 	/**
@@ -45,15 +48,103 @@ class Minz_Translate {
 	 * @param $lang_name the new language to use
 	 */
 	public static function reset($lang_name) {
-		self::init(self::$lang_list, $lang_name);
+		self::$lang_name = $lang_name;
+		self::$lang_files = array();
+		self::$translates = array();
+		foreach (self::$path_list as $path) {
+			self::loadLang($path);
+		}
 	}
 
 	/**
 	 * Return the list of available languages.
-	 * @return an array.
+	 * @return an array containing langs found in different registered paths.
 	 */
 	public static function availableLanguages() {
-		return self::$lang_list;
+		$list_langs = array();
+
+		foreach (self::$path_list as $path) {
+			$path_langs = array_values(array_diff(
+				scandir($path),
+				array('..', '.')
+			));
+
+			$list_langs = array_merge($list_langs, $path_langs);
+		}
+
+		return array_unique($list_langs);
+	}
+
+	/**
+	 * Register a new path.
+	 * @param $path a path containing i18n directories (e.g. ./en/, ./fr/).
+	 */
+	public static function registerPath($path) {
+		if (in_array($path, self::$path_list)) {
+			return;
+		}
+
+		self::$path_list[] = $path;
+		self::loadLang($path);
+	}
+
+	/**
+	 * Load translations of the current language from the given path.
+	 * @param $path the path containing i18n directories.
+	 */
+	private static function loadLang($path) {
+		$lang_path = $path . '/' . self::$lang_name;
+		if (!file_exists($lang_path) || is_null(self::$lang_name)) {
+			// The lang path does not exist, nothing more to do.
+			return;
+		}
+
+		$list_i18n_files = array_values(array_diff(
+			scandir($lang_path),
+			array('..', '.')
+		));
+
+		// Each file basename correspond to a top-level i18n key. For each of
+		// these keys we store the file pathname and mark translations must be
+		// reloaded (by setting $translates[$i18n_key] to null).
+		foreach ($list_i18n_files as $i18n_filename) {
+			$i18n_key = basename($i18n_filename, '.php');
+			if (!isset(self::$lang_files[$i18n_key])) {
+				self::$lang_files[$i18n_key] = array();
+			}
+			self::$lang_files[$i18n_key][] = $lang_path . '/' . $i18n_filename;
+			self::$translates[$i18n_key] = null;
+		}
+	}
+
+	/**
+	 * Load the files associated to $key into $translates.
+	 * @param $key the top level i18n key we want to load.
+	 */
+	private static function loadKey($key) {
+		// The top level key is not in $lang_files, it means it does not exist!
+		if (!isset(self::$lang_files[$key])) {
+			Minz_Log::debug($key . ' is not a valid top level key');
+			return false;
+		}
+
+		self::$translates[$key] = array();
+
+		foreach (self::$lang_files[$key] as $lang_pathname) {
+			$i18n_array = include($lang_pathname);
+			if (!is_array($i18n_array)) {
+				Minz_Log::warning('`' . $lang_pathname . '` does not contain a PHP array');
+				continue;
+			}
+
+			// We must avoid to erase previous data so we just override them if
+			// needed.
+			self::$translates[$key] = array_replace_recursive(
+				self::$translates[$key], $i18n_array
+			);
+		}
+
+		return true;
 	}
 
 	/**
@@ -73,16 +164,14 @@ class Minz_Translate {
 			$top_level = array_shift($group);
 		}
 
-		$filename = self::$lang_path . $top_level . '.php';
-
-		// Try to load the i18n file if it's not done yet.
-		if (!isset(self::$translates[$top_level])) {
-			if (!file_exists($filename)) {
-				Minz_Log::debug($top_level . ' is not a valid top level key');
+		// If $translates[$top_level] is null it means we have to load the
+		// corresponding files.
+		if (!isset(self::$translates[$top_level]) ||
+				is_null(self::$translates[$top_level])) {
+			$res = self::loadKey($top_level);
+			if (!$res) {
 				return $key;
 			}
-
-			self::$translates[$top_level] = include($filename);
 		}
 
 		// Go through the i18n keys to get the correct translation value.

+ 42 - 22
lib/Minz/View.php

@@ -13,8 +13,9 @@ class Minz_View {
 	const LAYOUT_FILENAME = '/layout.phtml';
 
 	private $view_filename = '';
-	private $use_layout = null;
+	private $use_layout = true;
 
+	private static $base_pathnames = array(APP_PATH);
 	private static $title = '';
 	private static $styles = array ();
 	private static $scripts = array ();
@@ -37,19 +38,26 @@ class Minz_View {
 	 * Change le fichier de vue en fonction d'un controller / action
 	 */
 	public function change_view($controller_name, $action_name) {
-		$this->view_filename = APP_PATH
-		                     . self::VIEWS_PATH_NAME . '/'
+		$this->view_filename = self::VIEWS_PATH_NAME . '/'
 		                     . $controller_name . '/'
 		                     . $action_name . '.phtml';
 	}
 
+	/**
+	 * Add a base pathname to search views.
+	 *
+	 * New pathnames will be added at the beginning of the list.
+	 *
+	 * @param $base_pathname the new base pathname.
+	 */
+	public static function addBasePathname($base_pathname) {
+		array_unshift(self::$base_pathnames, $base_pathname);
+	}
+
 	/**
 	 * Construit la vue
 	 */
 	public function build () {
-		if ($this->use_layout === null) {	//TODO: avoid file_exists and require views to be explicit
-			$this->use_layout = file_exists (APP_PATH . self::LAYOUT_PATH_NAME . self::LAYOUT_FILENAME);
-		}
 		if ($this->use_layout) {
 			$this->buildLayout ();
 		} else {
@@ -57,22 +65,40 @@ class Minz_View {
 		}
 	}
 
+	/**
+	 * Include a view file.
+	 *
+	 * The file is searched inside list of $base_pathnames.
+	 *
+	 * @param $filename the name of the file to include.
+	 * @return true if the file has been included, false else.
+	 */
+	private function includeFile($filename) {
+		// We search the filename in the list of base pathnames. Only the first view
+		// found is considered.
+		foreach (self::$base_pathnames as $base) {
+			$absolute_filename = $base . $filename;
+			if (file_exists($absolute_filename)) {
+				include $absolute_filename;
+				return true;
+			}
+		}
+
+		return false;
+	}
+
 	/**
 	 * Construit le layout
 	 */
 	public function buildLayout () {
-		include (
-			APP_PATH
-			. self::LAYOUT_PATH_NAME
-			. self::LAYOUT_FILENAME
-		);
+		$this->includeFile(self::LAYOUT_PATH_NAME . self::LAYOUT_FILENAME);
 	}
 
 	/**
 	 * Affiche la Vue en elle-même
 	 */
 	public function render () {
-		if ((include($this->view_filename)) === false) {
+		if (!$this->includeFile($this->view_filename)) {
 			Minz_Log::notice('File not found: `' . $this->view_filename . '`');
 		}
 	}
@@ -82,11 +108,8 @@ class Minz_View {
 	 * @param $part l'élément partial à ajouter
 	 */
 	public function partial ($part) {
-		$fic_partial = APP_PATH
-		             . self::LAYOUT_PATH_NAME . '/'
-		             . $part . '.phtml';
-
-		if ((include($fic_partial)) === false) {
+		$fic_partial = self::LAYOUT_PATH_NAME . '/' . $part . '.phtml';
+		if (!$this->includeFile($fic_partial)) {
 			Minz_Log::warning('File not found: `' . $fic_partial . '`');
 		}
 	}
@@ -96,11 +119,8 @@ class Minz_View {
 	 * @param $helper l'élément à afficher
 	 */
 	public function renderHelper ($helper) {
-		$fic_helper = APP_PATH
-		            . '/views/helpers/'
-		            . $helper . '.phtml';
-
-		if ((include($fic_helper)) === false) {;
+		$fic_helper = '/views/helpers/' . $helper . '.phtml';
+		if (!$this->includeFile($fic_helper)) {
 			Minz_Log::warning('File not found: `' . $fic_helper . '`');
 		}
 	}

+ 25 - 0
lib/lib_rss.php

@@ -375,6 +375,7 @@ function recursive_unlink($dir) {
 	if (!is_dir($dir)) {
 		return true;
 	}
+
 	$files = array_diff(scandir($dir), array('.', '..'));
 	foreach ($files as $filename) {
 		$filename = $dir . '/' . $filename;
@@ -385,6 +386,7 @@ function recursive_unlink($dir) {
 			unlink($filename);
 		}
 	}
+
 	return rmdir($dir);
 }
 
@@ -404,3 +406,26 @@ function remove_query_by_get($get, $queries) {
 	}
 	return $final_queries;
 }
+
+
+/**
+ * Add a value in an array and take care it is unique.
+ * @param $array the array in which we add the value.
+ * @param $value the value to add.
+ */
+function array_push_unique(&$array, $value) {
+	$found = array_search($value, $array) !== false;
+	if (!$found) {
+		$array[] = $value;
+	}
+}
+
+
+/**
+ * Remove a value from an array.
+ * @param $array the array from wich value is removed.
+ * @param $value the value to remove.
+ */
+function array_remove(&$array, $value) {
+	$array = array_diff($array, array($value));
+}

+ 50 - 17
p/ext.php

@@ -1,32 +1,65 @@
 <?php
-if (!isset($_GET['e'])) {
+if (!isset($_GET['f']) ||
+		!isset($_GET['t'])) {
 	header('HTTP/1.1 400 Bad Request');
 	die();
 }
-$extension = substr($_GET['e'], 0, 64);
-if (!ctype_alpha($extension)) {
+
+require('../constants.php');
+
+/**
+ * Check if a file can be served by ext.php. A valid file is under a
+ * EXTENSIONS_PATH/extension_name/static/ directory.
+ *
+ * You should sanitize path by using the realpath() function.
+ *
+ * @param $path the path to the file we want to serve.
+ * @return true if it can be served, false else.
+ *
+ */
+function is_valid_path($path) {
+	// It must be under the extension path.
+	$in_ext_path = (substr($path, 0, strlen(EXTENSIONS_PATH)) === EXTENSIONS_PATH);
+	if (!$in_ext_path) {
+		return false;
+	}
+
+	// File to serve must be under a `ext_dir/static/` directory.
+	$path_relative_to_ext = substr($path, strlen(EXTENSIONS_PATH) + 1);
+	$path_splitted = explode('/', $path_relative_to_ext);
+	if (count($path_splitted) < 3 || $path_splitted[1] !== 'static') {
+		return false;
+	}
+
+	return true;
+}
+
+$file_name = urldecode($_GET['f']);
+$file_type = $_GET['t'];
+
+$absolute_filename = realpath(EXTENSIONS_PATH . '/' . $file_name);
+
+if (!is_valid_path($absolute_filename)) {
 	header('HTTP/1.1 400 Bad Request');
 	die();
 }
 
-require('../constants.php');
-$filename = FRESHRSS_PATH . '/extensions/' . $extension . '/';
-
-if (isset($_GET['j'])) {
-	header('Content-Type: application/javascript; charset=UTF-8');
-	header('Content-Disposition: inline; filename="script.js"');
-	$filename .= 'script.js';
-} elseif (isset($_GET['c'])) {
+switch ($file_type) {
+case 'css':
 	header('Content-Type: text/css; charset=UTF-8');
-	header('Content-Disposition: inline; filename="style.css"');
-	$filename .= 'style.css';
-} else {
+	header('Content-Disposition: inline; filename="' . $file_name . '"');
+	break;
+case 'js':
+	header('Content-Type: application/javascript; charset=UTF-8');
+	header('Content-Disposition: inline; filename="' . $file_name . '"');
+	break;
+default:
 	header('HTTP/1.1 400 Bad Request');
 	die();
 }
 
-$mtime = @filemtime($filename);
-if ($mtime == false) {
+$mtime = @filemtime($absolute_filename);
+if ($mtime === false) {
 	header('HTTP/1.1 404 Not Found');
 	die();
 }
@@ -34,5 +67,5 @@ if ($mtime == false) {
 require(LIB_PATH . '/http-conditional.php');
 
 if (!httpConditional($mtime, 604800, 2)) {
-	readfile($filename);
+	readfile($absolute_filename);
 }