Kaynağa Gözat

Merge pull request #1078 from Alkarex/CSP-no-inline

Content-Security-Policy
Alexandre Alapetite 10 yıl önce
ebeveyn
işleme
3b2f9533c3

+ 2 - 0
CHANGELOG.md

@@ -2,6 +2,8 @@
 
 ## 2016-xx-xx FreshRSS 1.3.1-beta
 
+* Security
+	* Added CSP `Content-Security-Policy: default-src 'self'; child-src *; img-src * data:; media-src *` [#1075](https://github.com/FreshRSS/FreshRSS/pull/1075)
 * UI
 	* Fixed several small bugs in global and reader view [#1050](https://github.com/FreshRSS/FreshRSS/pull/1050)
 	* Updated to jQuery 2.2 and changed code for auto-load on scroll [#1050](https://github.com/FreshRSS/FreshRSS/pull/1050)

+ 1 - 1
app/Controllers/javascriptController.php

@@ -6,7 +6,7 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 	}
 
 	public function actualizeAction() {
-		header('Content-Type: text/javascript; charset=UTF-8');
+		header('Content-Type: application/json; charset=UTF-8');
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
 	}

+ 14 - 0
app/FreshRSS.php

@@ -110,6 +110,20 @@ class FreshRSS extends Minz_FrontController {
 		}
 	}
 
+	public static function preLayout() {
+		switch (Minz_Request::controllerName()) {
+			case 'index':
+				header("Content-Security-Policy: default-src 'self'; child-src *; img-src * data:; media-src *");
+				break;
+			case 'stats':
+				header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'");
+				break;
+			default:
+				header("Content-Security-Policy: default-src 'self'");
+				break;
+		}
+	}
+
 	private function loadNotifications() {
 		$notif = Minz_Session::param('notification');
 		if ($notif) {

+ 10 - 10
app/Models/StatsDAO.php

@@ -55,9 +55,9 @@ SQL;
 
 	/**
 	 * Calculates entry count per day on a 30 days period.
-	 * Returns the result as a JSON string.
+	 * Returns the result as a JSON object.
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	public function calculateEntryCount() {
 		$count = $this->initEntryCountArray();
@@ -257,9 +257,9 @@ SQL;
 
 	/**
 	 * Calculates feed count per category.
-	 * Returns the result as a JSON string.
+	 * Returns the result as a JSON object.
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	public function calculateFeedByCategory() {
 		$sql = <<<SQL
@@ -282,7 +282,7 @@ SQL;
 	 * Calculates entry count per category.
 	 * Returns the result as a JSON string.
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	public function calculateEntryByCategory() {
 		$sql = <<<SQL
@@ -357,7 +357,7 @@ SQL;
 			$serie[] = array($key, $value);
 		}
 
-		return json_encode($serie);
+		return $serie;
 	}
 
 	protected function convertToPieSerie($data) {
@@ -368,7 +368,7 @@ SQL;
 			$serie[] = $value;
 		}
 
-		return json_encode($serie);
+		return $serie;
 	}
 
 	/**
@@ -411,17 +411,17 @@ SQL;
 	}
 
 	/**
-	 * Translates array content and encode it as JSON
+	 * Translates array content
 	 *
 	 * @param array $data
-	 * @return string
+	 * @return JSON object
 	 */
 	private function convertToTranslatedJson($data = array()) {
 		$translated = array_map(function($a) {
 			return _t('gen.date.' . $a);
 		}, $data);
 
-		return json_encode($translated);
+		return $translated;
 	}
 
 }

+ 2 - 2
app/Models/StatsDAOSQLite.php

@@ -4,9 +4,9 @@ class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {
 
 	/**
 	 * Calculates entry count per day on a 30 days period.
-	 * Returns the result as a JSON string.
+	 * Returns the result as a JSON object.
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	public function calculateEntryCount() {
 		$count = $this->initEntryCountArray();

+ 1 - 1
app/i18n/cz/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Jste si jist, že chcete provést tuto akci? Změny nelze vrátit zpět!',
 		'confirm_action_feed_cat' => 'Jste si jist, že chcete provést tuto akci? Přijdete o související oblíbené položky a uživatelské dotazy. Změny nelze vrátit zpět!',
 		'feedback' => array(
-			'body_new_articles' => 'Je \\d nových článků k přečtení v FreshRSS.',
+			'body_new_articles' => 'Je %%d nových článků k přečtení v FreshRSS.',
 			'request_failed' => 'Požadavek selhal, což může být způsobeno problémy s připojení k internetu.',
 			'title_new_articles' => 'FreshRSS: nové články!',
 		),

+ 1 - 1
app/i18n/de/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Diese Aktion kann nicht abgebrochen werden!',
 		'confirm_action_feed_cat' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Sie werden zugehörige Favoriten und Benutzerabfragen verlieren. Dies kann nicht abgebrochen werden!',
 		'feedback' => array(
-			'body_new_articles' => 'Es gibt \\d neue Artikel zum Lesen auf FreshRSS.',
+			'body_new_articles' => 'Es gibt %%d neue Artikel zum Lesen auf FreshRSS.',
 			'request_failed' => 'Eine Anfrage ist fehlgeschlagen, dies könnte durch Probleme mit der Internetverbindung verursacht worden sein.',
 			'title_new_articles' => 'FreshRSS: neue Artikel!',
 		),

+ 1 - 1
app/i18n/en/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Are you sure you want to perform this action? It cannot be cancelled!',
 		'confirm_action_feed_cat' => 'Are you sure you want to perform this action? You will lose related favorites and user queries. It cannot be cancelled!',
 		'feedback' => array(
-			'body_new_articles' => 'There are \\d new articles to read on FreshRSS.',
+			'body_new_articles' => 'There are %%d new articles to read on FreshRSS.',
 			'request_failed' => 'A request has failed, it may have been caused by Internet connection problems.',
 			'title_new_articles' => 'FreshRSS: new articles!',
 		),

+ 1 - 1
app/i18n/fr/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Êtes-vous sûr(e) de vouloir continuer ? Cette action ne peut être annulée !',
 		'confirm_action_feed_cat' => 'Êtes-vous sûr(e) de vouloir continuer ? Vous perdrez les favoris et les filtres associés. Cette action ne peut être annulée !',
 		'feedback' => array(
-			'body_new_articles' => 'Il y a \\d nouveaux articles à lire sur FreshRSS.',
+			'body_new_articles' => 'Il y a %%d nouveaux articles à lire sur FreshRSS.',
 			'request_failed' => 'Une requête a échoué, cela peut être dû à des problèmes de connexion à Internet.',
 			'title_new_articles' => 'FreshRSS : nouveaux articles !',
 		),

+ 1 - 1
app/i18n/it/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Sei sicuro di voler continuare?',
 		'confirm_action_feed_cat' => 'Sei sicuro di voler continuare? Verranno persi i preferiti e le ricerche utente correlate!',
 		'feedback' => array(
-			'body_new_articles' => 'Ci sono \\d nuovi articoli da leggere.',
+			'body_new_articles' => 'Ci sono %%d nuovi articoli da leggere.',
 			'request_failed' => 'Richiesta fallita, probabilmente a causa di problemi di connessione',
 			'title_new_articles' => 'Feed RSS Reader: nuovi articoli!',
 		),

+ 1 - 1
app/i18n/nl/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Weet u zeker dat u dit wilt doen? Het kan niet ongedaan worden gemaakt!',
 		'confirm_action_feed_cat' => 'Weet u zeker dat u dit wilt doen? U verliest alle gereleteerde favorieten en gebruikers informatie. Het kan niet ongedaan worden gemaakt!',
 		'feedback' => array(
-			'body_new_articles' => 'Er zijn \\d nieuwe artikelen om te lezen op FreshRSS.',
+			'body_new_articles' => 'Er zijn %%d nieuwe artikelen om te lezen op FreshRSS.',
 			'request_failed' => 'Een opdracht is mislukt, mogelijk door Internet verbindings problemen.',
 			'title_new_articles' => 'FreshRSS: nieuwe artikelen!',
 		),

+ 1 - 1
app/i18n/tr/gen.php

@@ -108,7 +108,7 @@ return array(
 		'confirm_action' => 'Bunu yapmak istediğinize emin misiniz ? Daha sonra iptal edilemez!',
 		'confirm_action_feed_cat' => 'Bunu yapmak istediğinize emin misiniz ? Favorileriniz ve sorgularınız silinecek. Daha sonra iptal edilemez!',
 		'feedback' => array(
-			'body_new_articles' => 'FreshRSS de okunmaz üzere \\d yeni makale var.',
+			'body_new_articles' => 'FreshRSS de okunmaz üzere %%d yeni makale var.',
 			'request_failed' => 'Hata. İnternet bağlantınızı kontrol edin.',
 			'title_new_articles' => 'FreshRSS: yeni makaleler!',
 		),

+ 10 - 83
app/install.php

@@ -2,6 +2,7 @@
 if (function_exists('opcache_reset')) {
 	opcache_reset();
 }
+header("Content-Security-Policy: default-src 'self'");
 
 define('BCRYPT_COST', 9);
 
@@ -616,27 +617,6 @@ function printStep1() {
 		<a class="btn btn-attention next-step confirm" data-str-confirm="<?php echo _t('install.js.confirm_reinstall'); ?>" href="?step=2" tabindex="2" ><?php echo _t('install.action.reinstall'); ?></a>
 	</form>
 
-	<script>
-		function ask_confirmation(e) {
-			var str_confirmation = this.getAttribute('data-str-confirm');
-			if (!str_confirmation) {
-				str_confirmation = "<?php echo _t('gen.js.confirm_action'); ?>";
-			}
-
-			if (!confirm(str_confirmation)) {
-				e.preventDefault();
-			}
-		}
-
-		function init_confirm() {
-			confirms = document.getElementsByClassName('confirm');
-			for (var i = 0 ; i < confirms.length ; i++) {
-				confirms[i].addEventListener('click', ask_confirmation);
-			}
-		}
-
-		init_confirm();
-	</script>
 	<?php } elseif ($res['all'] == 'ok') { ?>
 	<a class="btn btn-important next-step" href="?step=2" tabindex="1" ><?php echo _t('install.action.next_step'); ?></a>
 	<?php } else { ?>
@@ -674,7 +654,7 @@ function printStep2() {
 		<div class="form-group">
 			<label class="group-name" for="auth_type"><?php echo _t('install.auth.type'); ?></label>
 			<div class="group-controls">
-				<select id="auth_type" name="auth_type" required="required" onchange="auth_type_change(true)" tabindex="4">
+				<select id="auth_type" name="auth_type" required="required" tabindex="4">
 					<?php
 						function no_auth($auth_type) {
 							return !in_array($auth_type, array('form', 'persona', 'http_auth', 'none'));
@@ -709,48 +689,6 @@ function printStep2() {
 			</div>
 		</div>
 
-		<script>
-			function show_password() {
-				var button = this;
-				var passwordField = document.getElementById(button.getAttribute('data-toggle'));
-				passwordField.setAttribute('type', 'text');
-				button.className += ' active';
-
-				return false;
-			}
-			function hide_password() {
-				var button = this;
-				var passwordField = document.getElementById(button.getAttribute('data-toggle'));
-				passwordField.setAttribute('type', 'password');
-				button.className = button.className.replace(/(?:^|\s)active(?!\S)/g , '');
-
-				return false;
-			}
-			toggles = document.getElementsByClassName('toggle-password');
-			for (var i = 0 ; i < toggles.length ; i++) {
-				toggles[i].addEventListener('mousedown', show_password);
-				toggles[i].addEventListener('mouseup', hide_password);
-			}
-
-			function auth_type_change() {
-				var auth_value = document.getElementById('auth_type').value,
-				    password_input = document.getElementById('passwordPlain'),
-				    mail_input = document.getElementById('mail_login');
-
-				if (auth_value === 'form') {
-					password_input.required = true;
-					mail_input.required = false;
-				} else if (auth_value === 'persona') {
-					password_input.required = false;
-					mail_input.required = true;
-				} else {
-					password_input.required = false;
-					mail_input.required = false;
-				}
-			}
-			auth_type_change();
-		</script>
-
 		<div class="form-group form-actions">
 			<div class="group-controls">
 				<button type="submit" class="btn btn-important" tabindex="7" ><?php echo _t('gen.action.submit'); ?></button>
@@ -778,7 +716,7 @@ function printStep3() {
 		<div class="form-group">
 			<label class="group-name" for="type"><?php echo _t('install.bdd.type'); ?></label>
 			<div class="group-controls">
-				<select name="type" id="type" onchange="mySqlShowHide()" tabindex="1" >
+				<select name="type" id="type" tabindex="1">
 				<?php if (extension_loaded('pdo_mysql')) {?>
 				<option value="mysql"
 					<?php echo(isset($_SESSION['bd_type']) && $_SESSION['bd_type'] === 'mysql') ? 'selected="selected"' : ''; ?>>
@@ -831,19 +769,6 @@ function printStep3() {
 			</div>
 		</div>
 		</div>
-		<script>
-			function mySqlShowHide() {
-				document.getElementById('mysql').style.display = document.getElementById('type').value === 'mysql' ? 'block' : 'none';
-				if (document.getElementById('type').value !== 'mysql') {
-					document.getElementById('host').value = '';
-					document.getElementById('user').value = '';
-					document.getElementById('pass').value = '';
-					document.getElementById('base').value = '';
-					document.getElementById('prefix').value = '';
-				}
-			}
-			mySqlShowHide();
-		</script>
 
 		<div class="form-group form-actions">
 			<div class="group-controls">
@@ -897,13 +822,14 @@ case 5:
 }
 ?>
 <!DOCTYPE html>
-<html lang="fr">
+<html>
 	<head>
-		<meta charset="utf-8">
-		<meta name="viewport" content="initial-scale=1.0">
+		<meta charset="UTF-8" />
+		<meta name="viewport" content="initial-scale=1.0" />
 		<title><?php echo _t('install.title'); ?></title>
-		<link rel="stylesheet" type="text/css" media="all" href="../themes/base-theme/template.css" />
-		<link rel="stylesheet" type="text/css" media="all" href="../themes/Origine/origine.css" />
+		<link rel="stylesheet" href="../themes/base-theme/template.css?<?php echo @filemtime(PUBLIC_PATH . '/themes/base-theme/template.css'); ?>" />
+		<link rel="stylesheet" href="../themes/Origine/origine.css?<?php echo @filemtime(PUBLIC_PATH . '/themes/Origine/origine.css'); ?>" />
+		<meta name="robots" content="noindex,nofollow" />
 	</head>
 	<body>
 
@@ -950,5 +876,6 @@ case 5:
 		?>
 	</div>
 </div>
+	<script src="../scripts/install.js?<?php echo @filemtime(PUBLIC_PATH . '/scripts/install.js'); ?>"></script>
 	</body>
 </html>

+ 1 - 1
app/layout/aside_feed.phtml

@@ -19,7 +19,7 @@
 	<a href="<?php echo _url('index', 'about'); ?>"><?php echo _t('index.menu.about'); ?></a>
 	<?php } ?>
 
-	<form id="mark-read-aside" method="post" style="display: none"></form>
+	<form id="mark-read-aside" method="post" aria-hidden="true"></form>
 
 	<ul class="tree">
 		<li class="tree-folder category all<?php echo FreshRSS_Context::isCurrentGet('a') ? ' active' : ''; ?>">

+ 1 - 1
app/layout/aside_subscription.phtml

@@ -10,7 +10,7 @@
 	</li>
 
 	<li class="item">
-		<a onclick="return false;" href="javascript:(function(){var%20url%20=%20location.href;window.open('<?php echo Minz_Url::display(array('c' => 'feed', 'a' => 'add'), 'html', true); ?>&amp;url_rss='+encodeURIComponent(url), '_blank');})();">
+		<a class="bookmarkClick" href="javascript:(function(){var%20url%20=%20location.href;window.open('<?php echo Minz_Url::display(array('c' => 'feed', 'a' => 'add'), 'html', true); ?>&amp;url_rss='+encodeURIComponent(url), '_blank');})();">
 			<?php echo _t('sub.menu.bookmark'); ?>
 		</a>
 	</li>

+ 5 - 2
app/layout/layout.phtml

@@ -1,3 +1,6 @@
+<?php
+	FreshRSS::preLayout();
+?>
 <!DOCTYPE html>
 <html lang="<?php echo FreshRSS_Context::$user_conf->language; ?>" xml:lang="<?php echo FreshRSS_Context::$user_conf->language; ?>">
 	<head>
@@ -5,9 +8,9 @@
 		<meta name="viewport" content="initial-scale=1.0" />
 		<?php echo self::headTitle(); ?>
 		<?php echo self::headStyle(); ?>
-		<script>//<![CDATA[
+		<script id="jsonVars" type="application/json">
 <?php $this->renderHelper('javascript_vars'); ?>
-		//]]></script>
+		</script>
 		<?php echo self::headScript(); ?>
 <?php
 	$url_base = Minz_Request::currentRequest();

+ 1 - 1
app/layout/nav_menu.phtml

@@ -79,7 +79,7 @@
 		);
 	?>
 
-	<form id="mark-read-menu" method="post" style="display: none"></form>
+	<form id="mark-read-menu" method="post" aria-hidden="true"></form>
 
 	<div class="stick" id="nav_menu_read_all">
 		<?php $confirm = FreshRSS_Context::$user_conf->reading_confirm ? 'confirm' : ''; ?>

+ 1 - 1
app/views/extension/index.phtml

@@ -5,7 +5,7 @@
 
 	<h1><?php echo _t('admin.extensions.title'); ?></h1>
 
-	<form id="form-extension" method="post" style="display: none"></form>
+	<form id="form-extension" method="post" aria-hidden="true"></form>
 	<?php if (!empty($this->extension_list['system'])) { ?>
 	<h2><?php echo _t('admin.extensions.system'); ?></h2>
 	<?php

+ 1 - 1
app/views/feed/add.phtml

@@ -56,7 +56,7 @@
 					<option value="nc"><?php echo _t('sub.category.new'); ?></option>
 				</select>
 
-				<span style="display: none;">
+				<span aria-hidden="true">
 					<input type="text" name="new_category[name]" id="new_category_name" autocomplete="off" placeholder="<?php echo _t('sub.category.new'); ?>" />
 				</span>
 			</div>

+ 50 - 66
app/views/helpers/javascript_vars.phtml

@@ -1,70 +1,54 @@
-"use strict";
 <?php
-
 $mark = FreshRSS_Context::$user_conf->mark_when;
 $mail = Minz_Session::param('mail', false);
-$auto_actualize = Minz_Session::param('actualize_feeds', false);
-$hide_posts = !(FreshRSS_Context::$user_conf->display_posts || Minz_Request::actionName() === 'reader');
 $s = FreshRSS_Context::$user_conf->shortcuts;
-
-$url_login = Minz_Url::display(array(
-	'c' => 'auth',
-	'a' => 'login'
-), 'php');
-$url_logout = Minz_Url::display(array(
-	'c' => 'auth',
-	'a' => 'logout'
-), 'php');
-
-echo 'var context={',
-	'auto_remove_article:', FreshRSS_Context::isAutoRemoveAvailable() ? 'true' : 'false', ',',
-	'hide_posts:', $hide_posts ? 'true' : 'false', ',',
-	'display_order:"', Minz_Request::param('order', FreshRSS_Context::$user_conf->sort_order), '",',
-	'auto_mark_article:', $mark['article'] ? 'true' : 'false', ',',
-	'auto_mark_site:', $mark['site'] ? 'true' : 'false', ',',
-	'auto_mark_scroll:', $mark['scroll'] ? 'true' : 'false', ',',
-	'auto_load_more:', FreshRSS_Context::$user_conf->auto_load_more ? 'true' : 'false', ',',
-	'auto_actualize_feeds:', $auto_actualize ? 'true' : 'false', ',',
-	'does_lazyload:', FreshRSS_Context::$user_conf->lazyload ? 'true' : 'false', ',',
-	'sticky_post:', FreshRSS_Context::isStickyPostEnabled() ? 'true' : 'false', ',',
-	'html5_notif_timeout:', FreshRSS_Context::$user_conf->html5_notif_timeout, ',',
-	'auth_type:"', FreshRSS_Context::$system_conf->auth_type, '",',
-	'current_user_mail:', $mail ? ('"' . $mail . '"') : 'null', ',',
-	'current_view:"', Minz_Request::actionName(), '"',
-"},\n";
-
-echo 'shortcuts={',
-	'mark_read:"', @$s['mark_read'], '",',
-	'mark_favorite:"', @$s['mark_favorite'], '",',
-	'go_website:"', @$s['go_website'], '",',
-	'prev_entry:"', @$s['prev_entry'], '",',
-	'next_entry:"', @$s['next_entry'], '",',
-	'first_entry:"', @$s['first_entry'], '",',
-	'last_entry:"', @$s['last_entry'], '",',
-	'collapse_entry:"', @$s['collapse_entry'], '",',
-	'load_more:"', @$s['load_more'], '",',
-	'auto_share:"', @$s['auto_share'], '",',
-	'focus_search:"', @$s['focus_search'], '",',
-	'user_filter:"', @$s['user_filter'], '",',
-	'help:"', @$s['help'], '",',
-	'close_dropdown:"', @$s['close_dropdown'], '"',
-"},\n";
-
-echo 'url={',
-	'index:"', _url('index', 'index'), '",',
-	'login:"', $url_login, '",',
-	'logout:"', $url_logout, '",',
-	'help:"', FRESHRSS_WIKI, '"',
-"},\n";
-
-echo 'i18n={',
-	'confirmation_default:"', _t('gen.js.confirm_action'), '",',
-	'notif_title_articles:"', _t('gen.js.feedback.title_new_articles'), '",',
-	'notif_body_articles:"', _t('gen.js.feedback.body_new_articles'), '",',
-	'notif_request_failed:"', _t('gen.js.feedback.request_failed'), '",',
-	'category_empty:"', _t('gen.js.category_empty'), '"',
-"},\n";
-
-echo 'icons={',
-	'close:\'', _i('close'), '\'',
-"}\n";
+echo htmlspecialchars(json_encode(array(
+	'context' => array(
+		'auto_remove_article' => !!FreshRSS_Context::isAutoRemoveAvailable(),
+		'hide_posts' => !(FreshRSS_Context::$user_conf->display_posts || Minz_Request::actionName() === 'reader'),
+		'display_order' => Minz_Request::param('order', FreshRSS_Context::$user_conf->sort_order),
+		'auto_mark_article' => !!$mark['article'],
+		'auto_mark_site' => !!$mark['site'],
+		'auto_mark_scroll' => !!$mark['scroll'],
+		'auto_load_more' => !!FreshRSS_Context::$user_conf->auto_load_more,
+		'auto_actualize_feeds' => !!Minz_Session::param('actualize_feeds', false),
+		'does_lazyload' => !!FreshRSS_Context::$user_conf->lazyload ,
+		'sticky_post' => !!FreshRSS_Context::isStickyPostEnabled(),
+		'html5_notif_timeout' => FreshRSS_Context::$user_conf->html5_notif_timeout,
+		'auth_type' => FreshRSS_Context::$system_conf->auth_type,
+		'current_user_mail' => $mail ? ('"' . $mail . '"') : null,
+		'current_view' => Minz_Request::actionName(),
+	),
+	'shortcuts' => array(
+		'mark_read' => @$s['mark_read'],
+		'mark_favorite' => @$s['mark_favorite'],
+		'go_website' => @$s['go_website'],
+		'prev_entry' => @$s['prev_entry'],
+		'next_entry' => @$s['next_entry'],
+		'first_entry' => @$s['first_entry'],
+		'last_entry' => @$s['last_entry'],
+		'collapse_entry' => @$s['collapse_entry'],
+		'load_more' => @$s['load_more'],
+		'auto_share' => @$s['auto_share'],
+		'focus_search' => @$s['focus_search'],
+		'user_filter' => @$s['user_filter'],
+		'help' => @$s['help'],
+		'close_dropdown' => @$s['close_dropdown'],
+	),
+	'url' => array(
+		'index' => _url('index', 'index'),
+		'login' => Minz_Url::display(array('c' => 'auth', 'a' => 'login'), 'php'),
+		'logout' => Minz_Url::display(array('c' => 'auth', 'a' => 'logout'), 'php'),
+		'help' => FRESHRSS_WIKI,
+	),
+	'i18n' => array(
+		'confirmation_default' => _t('gen.js.confirm_action'),
+		'notif_title_articles' => _t('gen.js.feedback.title_new_articles'),
+		'notif_body_articles' => _t('gen.js.feedback.body_new_articles'),
+		'notif_request_failed' => _t('gen.js.feedback.request_failed'),
+		'category_empty' => _t('gen.js.category_empty'),
+	),
+	'icons' => array(
+		'close' => _i('close'),
+	),
+), JSON_UNESCAPED_UNICODE), ENT_NOQUOTES);

+ 1 - 1
app/views/helpers/pagination.phtml

@@ -14,7 +14,7 @@
 	);
 ?>
 
-<form id="mark-read-pagination" method="post" style="display: none"></form>
+<form id="mark-read-pagination" method="post" aria-hidden="true"></form>
 
 <ul class="pagination">
 	<li class="item pager-next">

+ 13 - 56
app/views/javascript/actualize.phtml

@@ -1,56 +1,13 @@
-"use strict";
-var feeds = [<?php foreach ($this->feeds as $feed) { ?>{<?php
-	?>url: "<?php echo Minz_Url::display(array('c' => 'feed', 'a' => 'actualize', 'params' => array('id' => $feed->id(), 'ajax' => '1')), 'php'); ?>",<?php
-	?>title: "<?php echo $feed->name(); ?>"<?php
-?>},<?php } ?>],
-	feed_processed = 0,
-	feed_count = feeds.length;
-
-function initProgressBar(init) {
-	if (init) {
-		$("body").after("\<div id=\"actualizeProgress\" class=\"notification good\">\
-			<?php echo _t('feedback.sub.actualize'); ?><br /><span class=\"title\">/</span><br />\
-			<span class=\"progress\">0 / " + feed_count + "</span>\
-		</div>");
-	} else {
-		window.location.reload();
-	}
-}
-function updateProgressBar(i, title_feed) {
-	$("#actualizeProgress .progress").html(i + " / " + feed_count);
-	$("#actualizeProgress .title").html(title_feed);
-}
-
-function updateFeeds() {
-	if (feed_count === 0) {
-		openNotification("<?php echo _t('feedback.sub.feed.no_refresh'); ?>", "good");
-		ajax_loading = false;
-		return;
-	}
-	initProgressBar(true);
-
-	for (var i = 0; i < 10; i++) {
-		updateFeed();
-	}
-}
-
-function updateFeed() {
-	var feed = feeds.pop();
-	if (feed == undefined) {
-		return;
-	}
-
-	$.ajax({
-		type: 'POST',
-		url: feed['url'],
-	}).complete(function (data) {
-		feed_processed++;
-		updateProgressBar(feed_processed, feed['title']);
-
-		if (feed_processed === feed_count) {
-			initProgressBar(false);
-		} else {
-			updateFeed();
-		}
-	});
-}
+<?php
+$feeds = array();
+foreach ($this->feeds as $feed) {
+	$feeds[] = array(
+		'url' => Minz_Url::display(array('c' => 'feed', 'a' => 'actualize', 'params' => array('id' => $feed->id(), 'ajax' => '1')), 'php'),
+		'title' => $feed->name(),
+	);
+}
+echo json_encode(array(
+	'feeds' => $feeds,
+	'feedback_no_refresh' => _t('feedback.sub.feed.no_refresh'),
+	'feedback_actualize' => _t('feedback.sub.actualize'),
+));

+ 1 - 1
app/views/stats/idle.phtml

@@ -18,7 +18,7 @@
 		<div class="stat">
 			<h2><?php echo _t('gen.date.' . $period); ?></h2>
 
-			<form id="form-delete" method="post" style="display: none"></form>
+			<form id="form-delete" method="post" aria-hidden="true"></form>
 
 			<?php foreach ($feeds as $feed) { ?>
 			<ul class="horizontal-list">

+ 14 - 60
app/views/stats/index.phtml

@@ -66,74 +66,28 @@
 
 	<div class="stat">
 		<h2><?php echo _t('admin.stats.entry_per_day'); ?></h2>
-		<div id="statsEntryPerDay" style="height: 300px"></div>
+		<div id="statsEntryPerDay" class="statGraph"></div>
 	</div>
 
 	<div class="stat half">
 		<h2><?php echo _t('admin.stats.feed_per_category'); ?></h2>
-		<div id="statsFeedPerCategory" style="height: 300px"></div>
+		<div id="statsFeedPerCategory" class="statGraph"></div>
 		<div id="statsFeedPerCategoryLegend"></div>
-	</div><!--
+	</div>
 
-	--><div class="stat half">
+	<div class="stat half">
 		<h2><?php echo _t('admin.stats.entry_per_category'); ?></h2>
-		<div id="statsEntryPerCategory" style="height: 300px"></div>
+		<div id="statsEntryPerCategory" class="statGraph"></div>
 		<div id="statsEntryPerCategoryLegend"></div>
 	</div>
 </div>
 
-<script>
-"use strict";
-function initStats() {
-	if (!window.Flotr) {
-		if (window.console) {
-			console.log('FreshRSS waiting for Flotr…');
-		}
-		window.setTimeout(initStats, 50);
-		return;
-	}
-	// Entry per day
-	var avg = [];
-	for (var i = -31; i <= 0; i++) {
-		avg.push([i, <?php echo $this->average?>]);
-	}
-	Flotr.draw(document.getElementById('statsEntryPerDay'),
-		[{
-			data: <?php echo $this->count ?>,
-			bars: {horizontal: false, show: true}
-		},{
-			data: avg,
-			lines: {show: true},
-			label: "<?php echo $this->average?>"
-		}],
-		{
-			grid: {verticalLines: false},
-			xaxis: {noTicks: 6, showLabels: false, tickDecimals: 0, min: -30.75, max: -0.25},
-			yaxis: {min: 0},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
-		});
-	// Feed per category
-	Flotr.draw(document.getElementById('statsFeedPerCategory'),
-		<?php echo $this->feedByCategory ?>,
-		{
-			grid: {verticalLines: false, horizontalLines: false},
-			pie: {explode: 10, show: true, labelFormatter: function(){return '';}},
-			xaxis: {showLabels: false},
-			yaxis: {showLabels: false},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return obj.series.label + ' - '+ numberFormat(obj.y) + ' ('+ (obj.fraction * 100).toFixed(1) + '%)';}},
-			legend: {container: document.getElementById('statsFeedPerCategoryLegend'), noColumns: 3}
-		});
-	// Entry per category
-	Flotr.draw(document.getElementById('statsEntryPerCategory'),
-		<?php echo $this->entryByCategory ?>,
-		{
-			grid: {verticalLines: false, horizontalLines: false},
-			pie: {explode: 10, show: true, labelFormatter: function(){return '';}},
-			xaxis: {showLabels: false},
-			yaxis: {showLabels: false},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return obj.series.label + ' - '+ numberFormat(obj.y) + ' ('+ (obj.fraction * 100).toFixed(1) + '%)';}},
-			legend: {container: document.getElementById('statsEntryPerCategoryLegend'), noColumns: 3}
-		});
-}
-initStats();
-</script>
+<script id="jsonStats" type="application/json"><?php
+echo htmlspecialchars(json_encode(array(
+	'average' => $this->average,
+	'dataCount' => $this->count,
+	'feedByCategory' => $this->feedByCategory,
+	'entryByCategory' => $this->entryByCategory,
+), JSON_UNESCAPED_UNICODE), ENT_NOQUOTES);
+?></script>
+<script src="../scripts/stats.js?<?php echo @filemtime(PUBLIC_PATH . '/scripts/stats.js'); ?>"></script>

+ 25 - 88
app/views/stats/repartition.phtml

@@ -30,108 +30,45 @@
 	<?php }?>
 
 	<div class="stat">
-	    <table>
+		<table>
 		<tr>
-		    <th><?php echo _t('admin.stats.status_total'); ?></th>
-		    <th><?php echo _t('admin.stats.status_read'); ?></th>
-		    <th><?php echo _t('admin.stats.status_unread'); ?></th>
-		    <th><?php echo _t('admin.stats.status_favorites'); ?></th>
+			<th><?php echo _t('admin.stats.status_total'); ?></th>
+			<th><?php echo _t('admin.stats.status_read'); ?></th>
+			<th><?php echo _t('admin.stats.status_unread'); ?></th>
+			<th><?php echo _t('admin.stats.status_favorites'); ?></th>
 		</tr>
 		<tr>
-		    <td class="numeric"><?php echo $this->repartition['total']; ?></td>
-		    <td class="numeric"><?php echo $this->repartition['read']; ?></td>
-		    <td class="numeric"><?php echo $this->repartition['unread']; ?></td>
-		    <td class="numeric"><?php echo $this->repartition['favorite']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['total']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['read']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['unread']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['favorite']; ?></td>
 		</tr>
-	    </table>
+		</table>
 	</div>
 
 	<div class="stat">
 		<h2><?php echo _t('admin.stats.entry_per_hour', $this->averageHour); ?></h2>
-		<div id="statsEntryPerHour" style="height: 300px"></div>
+		<div id="statsEntryPerHour" class="statGraph"></div>
 	</div>
 
 	<div class="stat half">
 		<h2><?php echo _t('admin.stats.entry_per_day_of_week', $this->averageDayOfWeek); ?></h2>
-		<div id="statsEntryPerDayOfWeek" style="height: 300px"></div>
-	</div><!--
+		<div id="statsEntryPerDayOfWeek" class="statGraph"></div>
+	</div>
 
-	--><div class="stat half">
+	<div class="stat half">
 		<h2><?php echo _t('admin.stats.entry_per_month', $this->averageMonth); ?></h2>
-		<div id="statsEntryPerMonth" style="height: 300px"></div>
+		<div id="statsEntryPerMonth" class="statGraph"></div>
 	</div>
 </div>
 
-<script>
-"use strict";
-function initStats() {
-	if (!window.Flotr) {
-		if (window.console) {
-			console.log('FreshRSS waiting for Flotr…');
-		}
-		window.setTimeout(initStats, 50);
-		return;
-	}
-	// Entry per hour
-	Flotr.draw(document.getElementById('statsEntryPerHour'),
-		[{
-			data: <?php echo $this->repartitionHour ?>,
-			bars: {horizontal: false, show: true}
-		}],
-		{
-			grid: {verticalLines: false},
-			xaxis: {noTicks: 23,
-				tickFormatter: function(x) {
-					var x = parseInt(x);
-					return x + 1;
-				},
-				min: -0.9,
-				max: 23.9,
-				tickDecimals: 0},
-			yaxis: {min: 0},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
-		});
-	// Entry per day of week
-	Flotr.draw(document.getElementById('statsEntryPerDayOfWeek'),
-		[{
-			data: <?php echo $this->repartitionDayOfWeek ?>,
-			bars: {horizontal: false, show: true}
-		}],
-		{
-			grid: {verticalLines: false},
-			xaxis: {noTicks: 6,
-				tickFormatter: function(x) {
-					var x = parseInt(x),
-					    days = <?php echo $this->days?>;
-					return days[x];
-				},
-				min: -0.9,
-				max: 6.9,
-				tickDecimals: 0},
-			yaxis: {min: 0},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
-		});
-	// Entry per month
-	Flotr.draw(document.getElementById('statsEntryPerMonth'),
-		[{
-			data: <?php echo $this->repartitionMonth ?>,
-			bars: {horizontal: false, show: true}
-		}],
-		{
-			grid: {verticalLines: false},
-			xaxis: {noTicks: 12,
-				tickFormatter: function(x) {
-					var x = parseInt(x),
-					    months = <?php echo $this->months?>;
-					return months[(x - 1)];
-				},
-				min: 0.1,
-				max: 12.9,
-				tickDecimals: 0},
-			yaxis: {min: 0},
-			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
-		});
-
-}
-initStats();
-</script>
+<script id="jsonRepartition" type="application/json"><?php
+echo htmlspecialchars(json_encode(array(
+	'repartitionHour' => $this->repartitionHour,
+	'repartitionDayOfWeek' => $this->repartitionDayOfWeek,
+	'days' => $this->days,
+	'repartitionMonth' => $this->repartitionMonth,
+	'months' => $this->months,
+), JSON_UNESCAPED_UNICODE), ENT_NOQUOTES);
+?></script>
+<script src="../scripts/repartition.js?<?php echo @filemtime(PUBLIC_PATH . '/scripts/repartition.js'); ?>"></script>

+ 2 - 2
app/views/subscription/index.phtml

@@ -28,7 +28,7 @@
 						</select>
 					</li>
 
-					<li class="input" style="display:none">
+					<li class="input" aria-hidden="true">
 						<input type="text" name="new_category[name]" id="new_category_name" autocomplete="off" placeholder="<?php echo _t('sub.category.new'); ?>" />
 					</li>
 
@@ -62,7 +62,7 @@
 		</ul>
 	</div>
 
-	<form id="controller-category" method="post" style="display: none;"></form>
+	<form id="controller-category" method="post" aria-hidden="true"></form>
 
 	<?php
 		foreach ($this->categories as $cat) {

+ 9 - 6
lib/Minz/Session.php

@@ -59,18 +59,21 @@ class Minz_Session {
 		}
 	}
 
+	public static function getCookieDir() {
+		// Get the script_name (e.g. /p/i/index.php) and keep only the path.
+		$cookie_dir = empty($_SERVER['REQUEST_URI']) ? '/' : $_SERVER['REQUEST_URI'];
+		if (substr($cookie_dir, -1) !== '/') {
+			$cookie_dir = dirname($cookie_dir) . '/';
+		}
+		return $cookie_dir;
+	}
 
 	/**
 	 * Spécifie la durée de vie des cookies
 	 * @param $l la durée de vie
 	 */
 	public static function keepCookie($l) {
-		// Get the script_name (e.g. /p/i/index.php) and keep only the path.
-		$cookie_dir = empty($_SERVER['REQUEST_URI']) ? '/' : $_SERVER['REQUEST_URI'];
-		if (substr($cookie_dir, -1) !== '/') {
-			$cookie_dir = dirname($cookie_dir) . '/';
-		}
-		session_set_cookie_params($l, $cookie_dir, '', false, true);
+		session_set_cookie_params($l, self::getCookieDir(), '', false, true);
 	}
 
 

+ 76 - 0
p/scripts/install.js

@@ -0,0 +1,76 @@
+"use strict";
+
+function show_password() {
+	var button = this;
+	var passwordField = document.getElementById(button.getAttribute('data-toggle'));
+	passwordField.setAttribute('type', 'text');
+	button.className += ' active';
+	return false;
+}
+function hide_password() {
+	var button = this;
+	var passwordField = document.getElementById(button.getAttribute('data-toggle'));
+	passwordField.setAttribute('type', 'password');
+	button.className = button.className.replace(/(?:^|\s)active(?!\S)/g , '');
+	return false;
+}
+var toggles = document.getElementsByClassName('toggle-password');
+for (var i = 0 ; i < toggles.length ; i++) {
+	toggles[i].addEventListener('mousedown', show_password);
+	toggles[i].addEventListener('mouseup', hide_password);
+}
+
+function auth_type_change() {
+	var auth_type = document.getElementById('auth_type');
+	if (auth_type) {
+		var auth_value = auth_type.value,
+			password_input = document.getElementById('passwordPlain'),
+			mail_input = document.getElementById('mail_login');
+
+		if (auth_value === 'form') {
+			password_input.required = true;
+			mail_input.required = false;
+		} else if (auth_value === 'persona') {
+			password_input.required = false;
+			mail_input.required = true;
+		} else {
+			password_input.required = false;
+			mail_input.required = false;
+		}
+	}
+}
+var auth_type = document.getElementById('auth_type');
+if (auth_type) {
+	auth_type_change();
+	auth_type.addEventListener('change', auth_type_change);
+}
+
+function mySqlShowHide() {
+	var mysql = document.getElementById('mysql');
+	if (mysql) {
+		mysql.style.display = document.getElementById('type').value === 'mysql' ? 'block' : 'none';
+		if (document.getElementById('type').value !== 'mysql') {
+			document.getElementById('host').value = '';
+			document.getElementById('user').value = '';
+			document.getElementById('pass').value = '';
+			document.getElementById('base').value = '';
+			document.getElementById('prefix').value = '';
+		}
+	}
+}
+var bd_type = document.getElementById('type');
+if (bd_type) {
+	mySqlShowHide();
+	bd_type.addEventListener('change', mySqlShowHide);
+}
+
+function ask_confirmation(e) {
+	var str_confirmation = this.getAttribute('data-str-confirm');
+	if (!confirm(str_confirmation)) {
+		e.preventDefault();
+	}
+}
+var confirms = document.getElementsByClassName('confirm');
+for (var i = 0 ; i < confirms.length ; i++) {
+	confirms[i].addEventListener('click', ask_confirmation);
+}

+ 66 - 11
p/scripts/main.js

@@ -767,6 +767,31 @@ function init_nav_entries() {
 	});
 }
 
+// <actualize>
+var feed_processed = 0;
+
+function updateFeed(feeds, feeds_count) {
+	var feed = feeds.pop();
+	if (feed == undefined) {
+		return;
+	}
+
+	$.ajax({
+		type: 'POST',
+		url: feed['url'],
+	}).complete(function (data) {
+		feed_processed++;
+		$("#actualizeProgress .progress").html(feed_processed + " / " + feeds_count);
+		$("#actualizeProgress .title").html(feed['title']);
+
+		if (feed_processed === feeds_count) {
+			window.location.reload();
+		} else {
+			updateFeed(feeds, feeds_count);
+		}
+	});
+}
+
 function init_actualize() {
 	var auto = false;
 
@@ -777,14 +802,25 @@ function init_actualize() {
 
 		ajax_loading = true;
 
-		$.getScript('./?c=javascript&a=actualize').done(function () {
-			if (auto && feed_count < 1) {
+		$.getJSON('./?c=javascript&a=actualize').done(function (data) {
+			if (auto && data.feeds.length < 1) {
 				auto = false;
 				ajax_loading = false;
 				return false;
 			}
-
-			updateFeeds();
+			if (data.feeds.length === 0) {
+				openNotification(data.feedback_no_refresh, "good");
+				ajax_loading = false;
+				return;
+			}
+			//Progress bar
+			var feeds_count = data.feeds.length;
+			$('body').after('<div id="actualizeProgress" class="notification good">' + data.feedback_actualize +
+				'<br /><span class="title">/</span><br /><span class="progress">0 / ' + feeds_count +
+				'</span></div>');
+			for (var i = 10; i > 0; i--) {
+				updateFeed(data.feeds, feeds_count);
+			}
 		});
 
 		return false;
@@ -795,7 +831,7 @@ function init_actualize() {
 		$("#actualize").click();
 	}
 }
-
+// </actualize>
 
 // <notification>
 var notification = null,
@@ -863,7 +899,7 @@ function notifs_html5_show(nb) {
 
 	var notification = new window.Notification(i18n['notif_title_articles'], {
 		icon: "../themes/icons/favicon-256.png",
-		body: i18n['notif_body_articles'].replace("\d", nb),
+		body: i18n['notif_body_articles'].replace('%d', nb),
 		tag: "freshRssNewArticles"
 	});
 
@@ -871,7 +907,7 @@ function notifs_html5_show(nb) {
 		window.location.reload();
 	}
 
-	if (context['html5_notif_timeout'] !== 0){
+	if (context['html5_notif_timeout'] !== 0) {
 		setTimeout(function() {
 			notification.close();
 		}, context['html5_notif_timeout'] * 1000);
@@ -899,7 +935,7 @@ function refreshUnreads() {
 
 			if ((incUnreadsFeed(null, feed_id, nbUnreads - feed_unreads) || isAll) &&	//Update of current view?
 				(nbUnreads - feed_unreads > 0)) {
-				$('#new-article').show();
+				$('#new-article').attr('aria-hidden', 'false').show();
 				new_articles = true;
 			};
 		});
@@ -1122,10 +1158,10 @@ function init_feed_observers() {
 	$('select[id="category"]').on('change', function() {
 		var detail = $('#new_category_name').parent();
 		if ($(this).val() === 'nc') {
-			detail.show();
+			detail.attr('aria-hidden', 'false').show();
 			detail.find('input').focus();
 		} else {
-			detail.hide();
+			detail.attr('aria-hidden', 'true').hide();
 		}
 	});
 }
@@ -1245,14 +1281,32 @@ function init_configuration_alert() {
 	});
 }
 
+function init_subscription() {
+	$('body').on('click', '.bookmarkClick', function (e) {
+		return false;
+	});
+}
+
+function parseJsonVars() {
+	var jsonVars = document.getElementById('jsonVars'),
+		json = JSON.parse(jsonVars.innerHTML);
+	jsonVars.outerHTML = '';
+	window.context = json.context;
+	window.shortcuts = json.shortcuts;
+	window.url = json.url;
+	window.i18n = json.i18n;
+	window.icons = json.icons;
+}
+
 function init_all() {
-	if (!(window.$ && window.context)) {
+	if (!window.$) {
 		if (window.console) {
 			console.log('FreshRSS waiting for JS…');
 		}
 		window.setTimeout(init_all, 50);
 		return;
 	}
+	parseJsonVars();
 	init_notifications();
 	init_confirm_action();
 	$stream = $('#stream');
@@ -1269,6 +1323,7 @@ function init_all() {
 		init_notifs_html5();
 		window.setInterval(refreshUnreads, 120000);
 	} else {
+		init_subscription();
 		init_crypto_form();
 		init_share_observers();
 		init_remove_observers();

+ 1 - 1
p/scripts/persona.js

@@ -1,7 +1,7 @@
 "use strict";
 
 function init_persona() {
-	if (!(navigator.id && window.$)) {
+	if (!(navigator.id && window.$ && window.url)) {
 		if (window.console) {
 			console.log('FreshRSS (Persona) waiting for JS…');
 		}

+ 72 - 0
p/scripts/repartition.js

@@ -0,0 +1,72 @@
+"use strict";
+function initStats() {
+	if (!window.Flotr) {
+		if (window.console) {
+			console.log('FreshRSS waiting for Flotr…');
+		}
+		window.setTimeout(initStats, 50);
+		return;
+	}
+	var jsonRepartition = document.getElementById('jsonRepartition'),
+		stats = JSON.parse(jsonRepartition.innerHTML);
+	jsonRepartition.outerHTML = '';
+	// Entry per hour
+	Flotr.draw(document.getElementById('statsEntryPerHour'),
+		[{
+			data: stats.repartitionHour,
+			bars: {horizontal: false, show: true}
+		}],
+		{
+			grid: {verticalLines: false},
+			xaxis: {noTicks: 23,
+				tickFormatter: function(x) {
+					var x = parseInt(x);
+					return x + 1;
+				},
+				min: -0.9,
+				max: 23.9,
+				tickDecimals: 0},
+			yaxis: {min: 0},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
+		});
+	// Entry per day of week
+	Flotr.draw(document.getElementById('statsEntryPerDayOfWeek'),
+		[{
+			data: stats.repartitionDayOfWeek,
+			bars: {horizontal: false, show: true}
+		}],
+		{
+			grid: {verticalLines: false},
+			xaxis: {noTicks: 6,
+				tickFormatter: function(x) {
+					var x = parseInt(x);
+					return stats.days[x];
+				},
+				min: -0.9,
+				max: 6.9,
+				tickDecimals: 0},
+			yaxis: {min: 0},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
+		});
+	// Entry per month
+	Flotr.draw(document.getElementById('statsEntryPerMonth'),
+		[{
+			data: stats.repartitionMonth,
+			bars: {horizontal: false, show: true}
+		}],
+		{
+			grid: {verticalLines: false},
+			xaxis: {noTicks: 12,
+				tickFormatter: function(x) {
+					var x = parseInt(x);
+					return stats.months[(x - 1)];
+				},
+				min: 0.1,
+				max: 12.9,
+				tickDecimals: 0},
+			yaxis: {min: 0},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
+		});
+
+}
+initStats();

+ 56 - 0
p/scripts/stats.js

@@ -0,0 +1,56 @@
+"use strict";
+function initStats() {
+	if (!window.Flotr) {
+		if (window.console) {
+			console.log('FreshRSS waiting for Flotr…');
+		}
+		window.setTimeout(initStats, 50);
+		return;
+	}
+	var jsonStats = document.getElementById('jsonStats'),
+		stats = JSON.parse(jsonStats.innerHTML);
+	jsonStats.outerHTML = '';
+	// Entry per day
+	var avg = [];
+	for (var i = -31; i <= 0; i++) {
+		avg.push([i, stats.average]);
+	}
+	Flotr.draw(document.getElementById('statsEntryPerDay'),
+		[{
+			data: stats.dataCount,
+			bars: {horizontal: false, show: true}
+		},{
+			data: avg,
+			lines: {show: true},
+			label: stats.average,
+		}],
+		{
+			grid: {verticalLines: false},
+			xaxis: {noTicks: 6, showLabels: false, tickDecimals: 0, min: -30.75, max: -0.25},
+			yaxis: {min: 0},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return numberFormat(obj.y);}}
+		});
+	// Feed per category
+	Flotr.draw(document.getElementById('statsFeedPerCategory'),
+		stats.feedByCategory,
+		{
+			grid: {verticalLines: false, horizontalLines: false},
+			pie: {explode: 10, show: true, labelFormatter: function(){return '';}},
+			xaxis: {showLabels: false},
+			yaxis: {showLabels: false},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return obj.series.label + ' - '+ numberFormat(obj.y) + ' ('+ (obj.fraction * 100).toFixed(1) + '%)';}},
+			legend: {container: document.getElementById('statsFeedPerCategoryLegend'), noColumns: 3}
+		});
+	// Entry per category
+	Flotr.draw(document.getElementById('statsEntryPerCategory'),
+		stats.entryByCategory,
+		{
+			grid: {verticalLines: false, horizontalLines: false},
+			pie: {explode: 10, show: true, labelFormatter: function(){return '';}},
+			xaxis: {showLabels: false},
+			yaxis: {showLabels: false},
+			mouse: {relative: true, track: true, trackDecimals: 0, trackFormatter: function(obj) {return obj.series.label + ' - '+ numberFormat(obj.y) + ' ('+ (obj.fraction * 100).toFixed(1) + '%)';}},
+			legend: {container: document.getElementById('statsEntryPerCategoryLegend'), noColumns: 3}
+		});
+}
+initStats();

+ 8 - 0
p/themes/base-theme/template.css

@@ -110,6 +110,11 @@ td.numeric {
 
 /*=== COMPONENTS */
 /*===============*/
+
+[aria-hidden="true"] {
+	display: none;
+}
+
 /*=== Forms */
 .form-group::after {
 	content: "";
@@ -620,6 +625,9 @@ br + br + br {
 .stat > table {
 	width: 100%;
 }
+.statGraph {
+	height: 300px;
+}
 
 /*=== GLOBAL VIEW */
 /*================*/