Преглед изворни кода

Merge pull request #1338 from Alkarex/cli

CLI: Command-Line Interface
Alexandre Alapetite пре 9 година
родитељ
комит
062c65d228
52 измењених фајлова са 1026 додато и 417 уклоњено
  1. 3 1
      CHANGELOG.md
  2. 5 2
      README.fr.md
  3. 7 4
      README.md
  4. 2 3
      app/Controllers/categoryController.php
  5. 2 4
      app/Controllers/feedController.php
  6. 220 131
      app/Controllers/importExportController.php
  7. 84 77
      app/Controllers/userController.php
  8. 14 0
      app/Exceptions/ZipException.php
  9. 4 0
      app/Exceptions/ZipMissingException.php
  10. 7 4
      app/Models/CategoryDAO.php
  11. 1 1
      app/Models/Context.php
  12. 1 1
      app/Models/Feed.php
  13. 7 0
      app/Models/FeedDAO.php
  14. 17 1
      app/Models/UserDAO.php
  15. 3 0
      app/SQL/install.sql.mysql.php
  16. 4 0
      app/SQL/install.sql.pgsql.php
  17. 4 0
      app/SQL/install.sql.sqlite.php
  18. 2 2
      app/actualize_script.php
  19. 3 3
      app/i18n/cz/feedback.php
  20. 2 2
      app/i18n/cz/sub.php
  21. 3 3
      app/i18n/de/feedback.php
  22. 1 1
      app/i18n/de/sub.php
  23. 3 3
      app/i18n/en/feedback.php
  24. 2 2
      app/i18n/en/sub.php
  25. 3 3
      app/i18n/fr/feedback.php
  26. 2 2
      app/i18n/fr/sub.php
  27. 3 3
      app/i18n/it/feedback.php
  28. 2 2
      app/i18n/it/sub.php
  29. 3 3
      app/i18n/nl/feedback.php
  30. 2 2
      app/i18n/nl/sub.php
  31. 3 3
      app/i18n/ru/feedback.php
  32. 2 2
      app/i18n/ru/sub.php
  33. 3 3
      app/i18n/tr/feedback.php
  34. 2 2
      app/i18n/tr/sub.php
  35. 44 133
      app/install.php
  36. 1 1
      app/views/importExport/index.phtml
  37. 3 0
      cli/.htaccess
  38. 54 0
      cli/README.md
  39. 49 0
      cli/_cli.php
  40. 23 0
      cli/actualize-user.php
  41. 48 0
      cli/create-user.php
  42. 32 0
      cli/delete-user.php
  43. 102 0
      cli/do-install.php
  44. 24 0
      cli/export-opml-for-user.php
  45. 30 0
      cli/export-zip-for-user.php
  46. 35 0
      cli/import-for-user.php
  47. 13 0
      cli/index.html
  48. 14 0
      cli/list-users.php
  49. 7 7
      lib/Minz/ModelPdo.php
  50. 115 0
      lib/lib_install.php
  51. 6 3
      lib/lib_rss.php
  52. 0 3
      p/themes/Pafat/pafat.css

+ 3 - 1
CHANGELOG.md

@@ -3,6 +3,8 @@
 ## 2016-10-XX FreshRSS 1.6.0-dev
 
 * API
+	* New Command-Line Interface (CLI) [#1095](https://github.com/FreshRSS/FreshRSS/issues/1095)
+		* Install, add/delete users, actualize, import/export. See [CLI documentation](./cli/README.md).
 	* Support for editing feeds and categories from client applications [#1254](https://github.com/FreshRSS/FreshRSS/issues/1254)
 * Compatibility:
 	* Support for PostgreSQL [#416](https://github.com/FreshRSS/FreshRSS/issues/416)
@@ -390,7 +392,7 @@
 	* Possibility to combine search filters, e.g. `date:2014-05 intitle:FreshRSS intitle:Open great reader #Internet`
 * Change nav menu with more buttons instead of dropdown menus and add some filters
 * New system of import / export
-	* Support OPML, Json (like Google Reader) and Zip archives
+	* Support OPML, Json (like Google Reader) and ZIP archives
 	* Can export and import articles (specific option for favorites)
 * Refactor "Origine" theme
 	* Some improvements

+ 5 - 2
README.fr.md

@@ -34,7 +34,7 @@ Nous sommes une communauté amicale.
 * Serveur Web Apache2 (recommandé), ou nginx, lighttpd (non testé sur les autres)
 * PHP 5.3.3+ (PHP 5.4+ recommandé, et PHP 5.5+ pour les performances, et PHP 7+ pour d’encore meilleures performances)
 	* Requis : [DOM](http://php.net/dom), [XML](http://php.net/xml), [PDO_MySQL](http://php.net/pdo-mysql) ou [PDO_SQLite](http://php.net/pdo-sqlite) ou [PDO_PGSQL](http://php.net/pdo-pgsql), [cURL](http://php.net/curl)
-	* Recommandés : [JSON](http://php.net/json), [GMP](http://php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](http://php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](http://php.net/mbstring) et/ou [iconv](http://php.net/iconv) (pour conversion d’encodages), [Zip](http://php.net/zip) (pour import/export), [zlib](http://php.net/zlib) (pour les flux compressés)
+	* Recommandés : [JSON](http://php.net/json), [GMP](http://php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](http://php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](http://php.net/mbstring) et/ou [iconv](http://php.net/iconv) (pour conversion d’encodages), [ZIP](http://php.net/zip) (pour import/export), [zlib](http://php.net/zlib) (pour les flux compressés)
 * MySQL 5.5.3+ (recommandé), ou SQLite 3.7.4+, ou PostgreSQL (experimental)
 * Un navigateur Web récent tel Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Fonctionne aussi sur mobile
@@ -46,7 +46,8 @@ Nous sommes une communauté amicale.
 2. Placez l’application sur votre serveur (la partie à exposer au Web est le répertoire `./p/`)
 3. Le serveur Web doit avoir les droits d’écriture dans le répertoire `./data/`
 4. Accédez à FreshRSS à travers votre navigateur Web et suivez les instructions d’installation
-5. Tout devrait fonctionner :) En cas de problème, n’hésitez pas à me contacter.
+	* ou utilisez [l’interface en ligne de commande](./cli/README.md)
+5. Tout devrait fonctionner :) En cas de problème, n’hésitez pas à [nous contacter](https://github.com/FreshRSS/FreshRSS/issues).
 6. Des paramètres de configuration avancée peuvent être accédés depuis [config.php](./data/config.default.php).
 
 ## Installation automatisée
@@ -87,6 +88,7 @@ sudo chmod -R g+w ./data/
 sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS
 # Naviguez vers http://example.net/FreshRSS pour terminer l’installation.
 # (Si vous le faite depuis localhost, vous pourrez avoir à ajuster le réglage de votre adresse publique)
+# ou utilisez l’interface en ligne de commande
 
 # Mettre à jour FreshRSS vers une nouvelle version
 cd /usr/share/FreshRSS
@@ -132,6 +134,7 @@ Créer `/etc/cron.d/FreshRSS` avec :
 # Sauvegarde
 * Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/*_user.php`
 * Vous pouvez exporter votre liste de flux depuis FreshRSS au format OPML
+	* soit depuis l’interface Web, soit [en ligne de commande](./cli/README.md)
 * Pour sauvegarder les articles eux-mêmes, vous pouvez utiliser [phpMyAdmin](http://www.phpmyadmin.net) ou les outils de MySQL :
 
 ```bash

+ 7 - 4
README.md

@@ -34,7 +34,7 @@ We are a friendly community.
 * A web server: Apache2 (recommended), nginx, lighttpd (not tested on others)
 * PHP 5.3.3+ (PHP 5.4+ recommended, and PHP 5.5+ for performance, and PHP 7 for even higher performance)
 	* Required extensions: [DOM](http://php.net/dom), [XML](http://php.net/xml), [PDO_MySQL](http://php.net/pdo-mysql) or [PDO_SQLite](http://php.net/pdo-sqlite) or [PDO_PGSQL](http://php.net/pdo-pgsql), [cURL](http://php.net/curl)
-	* Recommended extensions: [JSON](http://php.net/json), [GMP](http://php.net/gmp) (for API access on platforms < 64 bits), [IDN](http://php.net/intl.idn) (for Internationalized Domain Names), [mbstring](http://php.net/mbstring) and/or [iconv](http://php.net/iconv) (for charset conversion), [Zip](http://php.net/zip) (for import/export), [zlib](http://php.net/zlib) (for compressed feeds)
+	* Recommended extensions: [JSON](http://php.net/json), [GMP](http://php.net/gmp) (for API access on platforms < 64 bits), [IDN](http://php.net/intl.idn) (for Internationalized Domain Names), [mbstring](http://php.net/mbstring) and/or [iconv](http://php.net/iconv) (for charset conversion), [ZIP](http://php.net/zip) (for import/export), [zlib](http://php.net/zlib) (for compressed feeds)
 * MySQL 5.5.3+ (recommended), or SQLite 3.7.4+, or PostgreSQL (experimental)
 * A recent browser like Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Works on mobile
@@ -46,7 +46,8 @@ We are a friendly community.
 2. Dump the application on your server (expose only the `./p/` folder)
 3. Add write access on `./data/` folder to the webserver user
 4. Access FreshRSS with your browser and follow the installation process
-5. Everything should be working :) If you encounter any problem, feel free to contact me.
+	* or use the [Command-Line Interface](./cli/README.md)
+5. Everything should be working :) If you encounter any problem, feel free [contact us](https://github.com/FreshRSS/FreshRSS/issues).
 6. Advanced configuration settings can be seen in [config.php](./data/config.default.php).
 
 ## Automated install
@@ -87,6 +88,7 @@ sudo chmod -R g+w ./data/
 sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS
 # Navigate to http://example.net/FreshRSS to complete the installation.
 # (If you do it from localhost, you may have to adjust the setting of your public address later)
+# or use the Command-Line Interface
 
 # Update to a newer version of FreshRSS
 cd /usr/share/FreshRSS
@@ -107,8 +109,8 @@ It is needed for the multi-user mode to limit access to FreshRSS. You can:
 ## Automatic feed update
 * You can add a Cron job to launch the update script.
 Check the Cron documentation related to your distribution ([Debian/Ubuntu](https://help.ubuntu.com/community/CronHowto), [Red Hat/Fedora](https://fedoraproject.org/wiki/Administration_Guide_Draft/Cron), [Slackware](http://docs.slackware.com/fr:slackbook:process_control?#cron), [Gentoo](https://wiki.gentoo.org/wiki/Cron), [Arch Linux](https://wiki.archlinux.org/index.php/Cron)…).
-Its a good idea to use the Web server user.
-For example, if you want to run the script every hour:
+It is a good idea to use the Web server user.
+For instance, if you want to run the script every hour:
 
 ```
 9 * * * * php /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
@@ -132,6 +134,7 @@ Create `/etc/cron.d/FreshRSS` with:
 # Backup
 * You need to keep `./data/config.php`, and `./data/*_user.php` files
 * You can export your feed list in OPML format from FreshRSS
+	* either from the Web interface, or from the [Command-Line Interface](./cli/README.md)
 * To save articles, you can use [phpMyAdmin](http://www.phpmyadmin.net) or MySQL tools:
 
 ```bash

+ 2 - 3
app/Controllers/categoryController.php

@@ -117,7 +117,6 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	public function deleteAction() {
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$catDAO = new FreshRSS_CategoryDAO();
-		$default_category = $catDAO->getDefault();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 		if (Minz_Request::isPost()) {
@@ -128,11 +127,11 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
 			}
 
-			if ($id === $default_category->id()) {
+			if ($id === FreshRSS_CategoryDAO::defaultCategoryId) {
 				Minz_Request::bad(_t('feedback.sub.category.not_delete_default'), $url_redirect);
 			}
 
-			if ($feedDAO->changeCategory($id, $default_category->id()) === false) {
+			if ($feedDAO->changeCategory($id, FreshRSS_CategoryDAO::defaultCategoryId) === false) {
 				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 

+ 2 - 4
app/Controllers/feedController.php

@@ -40,9 +40,8 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 		if ($cat == null) {
 			$catDAO->checkDefault();
-			$cat = $catDAO->getDefault();
 		}
-		$cat_id = $cat->id();
+		$cat_id = $cat == null ? FreshRSS_CategoryDAO::defaultCategoryId : $cat->id();
 
 		$feed = new FreshRSS_Feed($url);	//Throws FreshRSS_BadUrl_Exception
 		$feed->_httpAuth($http_auth);
@@ -504,8 +503,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 		if ($cat_id <= 1) {
 			$catDAO->checkDefault();
-			$cat = $catDAO->getDefault();
-			$cat_id = $cat->id();
+			$cat_id = FreshRSS_CategoryDAO::defaultCategoryId;
 		}
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();

+ 220 - 131
app/Controllers/importExportController.php

@@ -29,32 +29,14 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		Minz_View::prependTitle(_t('sub.import_export.title') . ' · ');
 	}
 
-	/**
-	 * This action handles import action.
-	 *
-	 * It must be reached by a POST request.
-	 *
-	 * Parameter is:
-	 *   - file (default: nothing!)
-	 * Available file types are: zip, json or xml.
-	 */
-	public function importAction() {
-		if (!Minz_Request::isPost()) {
-			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
-		}
-
-		$file = $_FILES['file'];
-		$status_file = $file['error'];
-
-		if ($status_file !== 0) {
-			Minz_Log::warning('File cannot be uploaded. Error code: ' . $status_file);
-			Minz_Request::bad(_t('feedback.import_export.file_cannot_be_uploaded'),
-			                  array('c' => 'importExport', 'a' => 'index'));
-		}
+	public function importFile($name, $path, $username = null) {
+		require_once(LIB_PATH . '/lib_opml.php');
 
-		@set_time_limit(300);
+		$this->catDAO = new FreshRSS_CategoryDAO($username);
+		$this->entryDAO = FreshRSS_Factory::createEntryDao($username);
+		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
 
-		$type_file = $this->guessFileType($file['name']);
+		$type_file = self::guessFileType($name);
 
 		$list_files = array(
 			'opml' => array(),
@@ -65,21 +47,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		// We try to list all files according to their type
 		$list = array();
 		if ($type_file === 'zip' && extension_loaded('zip')) {
-			$zip = zip_open($file['tmp_name']);
-
+			$zip = zip_open($path);
 			if (!is_resource($zip)) {
 				// zip_open cannot open file: something is wrong
-				Minz_Log::warning('Zip archive cannot be imported. Error code: ' . $zip);
-				Minz_Request::bad(_t('feedback.import_export.zip_error'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_Zip_Exception($zip);
 			}
-
 			while (($zipfile = zip_read($zip)) !== false) {
 				if (!is_resource($zipfile)) {
 					// zip_entry() can also return an error code!
-					Minz_Log::warning('Zip file cannot be imported. Error code: ' . $zipfile);
+					throw new FreshRSS_Zip_Exception($zipfile);
 				} else {
-					$type_zipfile = $this->guessFileType(zip_entry_name($zipfile));
+					$type_zipfile = self::guessFileType(zip_entry_name($zipfile));
 					if ($type_file !== 'unknown') {
 						$list_files[$type_zipfile][] = zip_entry_read(
 							$zipfile,
@@ -88,29 +66,88 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 					}
 				}
 			}
-
 			zip_close($zip);
 		} elseif ($type_file === 'zip') {
-			// Zip extension is not loaded
-			Minz_Request::bad(_t('feedback.import_export.no_zip_extension'),
-			                  array('c' => 'importExport', 'a' => 'index'));
+			// ZIP extension is not loaded
+			throw new FreshRSS_ZipMissing_Exception();
 		} elseif ($type_file !== 'unknown') {
-			$list_files[$type_file][] = file_get_contents($file['tmp_name']);
+			$list_files[$type_file][] = file_get_contents($path);
 		}
 
 		// Import file contents.
 		// OPML first(so categories and feeds are imported)
 		// Starred articles then so the "favourite" status is already set
 		// And finally all other files.
-		$error = false;
+		$ok = true;
 		foreach ($list_files['opml'] as $opml_file) {
-			$error = $this->importOpml($opml_file);
+			if (!$this->importOpml($opml_file)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during OPML import' . "\n");
+				} else {
+					Minz_Log::warning('Error during OPML import');
+				}
+			}
 		}
 		foreach ($list_files['json_starred'] as $article_file) {
-			$error = $this->importJson($article_file, true);
+			if (!$this->importJson($article_file, true)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during JSON stars import' . "\n");
+				} else {
+					Minz_Log::warning('Error during JSON stars import');
+				}
+			}
 		}
 		foreach ($list_files['json_feed'] as $article_file) {
-			$error = $this->importJson($article_file);
+			if (!$this->importJson($article_file)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during JSON feeds import' . "\n");
+				} else {
+					Minz_Log::warning('Error during JSON feeds import');
+				}
+			}
+		}
+
+		return $ok;
+	}
+
+	/**
+	 * This action handles import action.
+	 *
+	 * It must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 *   - file (default: nothing!)
+	 * Available file types are: zip, json or xml.
+	 */
+	public function importAction() {
+		if (!Minz_Request::isPost()) {
+			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
+		}
+
+		$file = $_FILES['file'];
+		$status_file = $file['error'];
+
+		if ($status_file !== 0) {
+			Minz_Log::warning('File cannot be uploaded. Error code: ' . $status_file);
+			Minz_Request::bad(_t('feedback.import_export.file_cannot_be_uploaded'),
+			                  array('c' => 'importExport', 'a' => 'index'));
+		}
+
+		@set_time_limit(300);
+
+		$error = false;
+		try {
+			$error = !$this->importFile($file['name'], $file['tmp_name']);
+		} catch (FreshRSS_ZipMissing_Exception $zme) {
+			Minz_Request::bad(_t('feedback.import_export.no_zip_extension'),
+				array('c' => 'importExport', 'a' => 'index'));
+		} catch (FreshRSS_Zip_Exception $ze) {
+			Minz_Log::warning('ZIP archive cannot be imported. Error code: ' . $ze->zipErrorCode());
+			Minz_Request::bad(_t('feedback.import_export.zip_error'),
+				array('c' => 'importExport', 'a' => 'index'));
 		}
 
 		// And finally, we get import status and redirect to the home page
@@ -126,7 +163,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * Itis a *very* basic guess file type function. Only based on filename.
 	 * That's could be improved but should be enough for what we have to do.
 	 */
-	private function guessFileType($filename) {
+	private static function guessFileType($filename) {
 		if (substr_compare($filename, '.zip', -4) === 0) {
 			return 'zip';
 		} elseif (substr_compare($filename, '.opml', -5) === 0 ||
@@ -146,15 +183,19 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * This method parses and imports an OPML file.
 	 *
 	 * @param string $opml_file the OPML file content.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function importOpml($opml_file) {
 		$opml_array = array();
 		try {
 			$opml_array = libopml_parse_string($opml_file, false);
 		} catch (LibOPML_Exception $e) {
-			Minz_Log::warning($e->getMessage());
-			return true;
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML parsing: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
+			return false;
 		}
 
 		$this->catDAO->checkDefault();
@@ -167,51 +208,49 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param array $opml_elements an OPML element (body or outline).
 	 * @param string $parent_cat the name of the parent category.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function addOpmlElements($opml_elements, $parent_cat = null) {
-		$error = false;
+		$ok = true;
 
 		$nb_feeds = count($this->feedDAO->listFeeds());
 		$nb_cats = count($this->catDAO->listCategories(false));
 		$limits = FreshRSS_Context::$system_conf->limits;
 
 		foreach ($opml_elements as $elt) {
-			$is_error = false;
 			if (isset($elt['xmlUrl'])) {
 				// If xmlUrl exists, it means it is a feed
-				if ($nb_feeds >= $limits['max_feeds']) {
+				if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) {
 					Minz_Log::warning(_t('feedback.sub.feed.over_max',
-					                  $limits['max_feeds']));
-					$is_error = true;
+									  $limits['max_feeds']));
+					$ok = false;
 					continue;
 				}
 
-				$is_error = $this->addFeedOpml($elt, $parent_cat);
-				if (!$is_error) {
-					$nb_feeds += 1;
+				if ($this->addFeedOpml($elt, $parent_cat)) {
+					$nb_feeds++;
+				} else {
+					$ok = false;
 				}
 			} else {
 				// No xmlUrl? It should be a category!
 				$limit_reached = ($nb_cats >= $limits['max_categories']);
-				if ($limit_reached) {
+				if (!FreshRSS_Context::$isCli && $limit_reached) {
 					Minz_Log::warning(_t('feedback.sub.category.over_max',
-					                  $limits['max_categories']));
+									  $limits['max_categories']));
+					$ok = false;
+					continue;
 				}
 
-				$is_error = $this->addCategoryOpml($elt, $parent_cat, $limit_reached);
-				if (!$is_error) {
-					$nb_cats += 1;
+				if ($this->addCategoryOpml($elt, $parent_cat, $limit_reached)) {
+					$nb_cats++;
+				} else {
+					$ok = false;
 				}
 			}
-
-			if (!$error && $is_error) {
-				// oops: there is at least one error!
-				$error = $is_error;
-			}
 		}
 
-		return $error;
+		return $ok;
 	}
 
 	/**
@@ -219,21 +258,23 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param array $feed_elt an OPML element (must be a feed element).
 	 * @param string $parent_cat the name of the parent category.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function addFeedOpml($feed_elt, $parent_cat) {
-		$default_cat = $this->catDAO->getDefault();
-		if (is_null($parent_cat)) {
+		if ($parent_cat == null) {
 			// This feed has no parent category so we get the default one
+			$this->catDAO->checkDefault();
+			$default_cat = $this->catDAO->getDefault();
 			$parent_cat = $default_cat->name();
 		}
 
 		$cat = $this->catDAO->searchByName($parent_cat);
-		if (is_null($cat)) {
+		if ($cat == null) {
 			// If there is not $cat, it means parent category does not exist in
 			// database.
 			// If it happens, take the default category.
-			$cat = $default_cat;
+			$this->catDAO->checkDefault();
+			$cat = $this->catDAO->getDefault();
 		}
 
 		// We get different useful information
@@ -259,7 +300,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 
 			// Call the extension hook
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				// addFeedObject checks if feed is already in DB so nothing else to
 				// check here
 				$id = $this->feedDAO->addFeedObject($feed);
@@ -268,11 +309,23 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 				$error = true;
 			}
 		} catch (FreshRSS_Feed_Exception $e) {
-			Minz_Log::warning($e->getMessage());
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML feed import: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
 			$error = true;
 		}
 
-		return $error;
+		if ($error) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id() . "\n");
+			} else {
+				Minz_Log::warning('Error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id());
+			}
+		}
+
+		return !$error;
 	}
 
 	/**
@@ -282,29 +335,34 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param string $parent_cat the name of the parent category.
 	 * @param boolean $cat_limit_reached indicates if category limit has been reached.
 	 *                if yes, category is not added (but we try for feeds!)
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function addCategoryOpml($cat_elt, $parent_cat, $cat_limit_reached) {
 		// Create a new Category object
-		$cat = new FreshRSS_Category(Minz_Helper::htmlspecialchars_utf8($cat_elt['text']));
+		$catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']);
+		$cat = new FreshRSS_Category($catName);
 
 		$error = true;
-		if (!$cat_limit_reached) {
+		if (FreshRSS_Context::$isCli || !$cat_limit_reached) {
 			$id = $this->catDAO->addCategoryObject($cat);
 			$error = ($id === false);
 		}
+		if ($error) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n");
+			} else {
+				Minz_Log::warning('Error during OPML category import from URL: ' . $catName);
+			}
+		}
 
 		if (isset($cat_elt['@outlines'])) {
 			// Our cat_elt contains more categories or more feeds, so we
 			// add them recursively.
 			// Note: FreshRSS does not support yet category arborescence
-			$res = $this->addOpmlElements($cat_elt['@outlines'], $cat->name());
-			if (!$error && $res) {
-				$error = true;
-			}
+			$error &= !$this->addOpmlElements($cat_elt['@outlines'], $catName);
 		}
 
-		return $error;
+		return !$error;
 	}
 
 	/**
@@ -312,13 +370,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param string $article_file the JSON file content.
 	 * @param boolean $starred true if articles from the file must be starred.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function importJson($article_file, $starred = false) {
 		$article_object = json_decode($article_file, true);
-		if (is_null($article_object)) {
-			Minz_Log::warning('Try to import a non-JSON file');
-			return true;
+		if ($article_object == null) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error trying to import a non-JSON file' . "\n");
+			} else {
+				Minz_Log::warning('Try to import a non-JSON file');
+			}
+			return false;
 		}
 
 		$is_read = FreshRSS_Context::$user_conf->mark_when['reception'] ? 1 : 0;
@@ -337,25 +399,24 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$feed = new FreshRSS_Feed($item['origin'][$key]);
 			$feed = $this->feedDAO->searchByUrl($feed->url());
 
-			if (is_null($feed)) {
+			if ($feed == null) {
 				// Feed does not exist in DB,we should to try to add it.
-				if ($nb_feeds >= $limits['max_feeds']) {
+				if ((!FreshRSS_Context::$isCli) && ($nb_feeds >= $limits['max_feeds'])) {
 					// Oops, no more place!
 					Minz_Log::warning(_t('feedback.sub.feed.over_max', $limits['max_feeds']));
 				} else {
 					$feed = $this->addFeedJson($item['origin'], $google_compliant);
 				}
 
-				if (is_null($feed)) {
+				if ($feed == null) {
 					// Still null? It means something went wrong.
 					$error = true;
 				} else {
-					// Nice! Increase the counter.
-					$nb_feeds += 1;
+					$nb_feeds++;
 				}
 			}
 
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				$article_to_feed[$item['id']] = $feed->id();
 			}
 		}
@@ -384,7 +445,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			if ($google_compliant) {
 				// Remove tags containing "/state/com.google" which are useless.
 				$tags = array_filter($tags, function($var) {
-					return strpos($var, '/state/com.google') === false;
+					return strpos($var, '/state/com.google') !== false;
 				});
 			}
 
@@ -397,7 +458,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$entry->_tags($tags);
 
 			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
-			if (is_null($entry)) {
+			if ($entry == null) {
 				// An extension has returned a null value, there is nothing to insert.
 				continue;
 			}
@@ -415,7 +476,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		}
 		$this->entryDAO->commit();
 
-		return $error;
+		return !$error;
 	}
 
 	/**
@@ -427,8 +488,6 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *         else null.
 	 */
 	private function addFeedJson($origin, $google_compliant) {
-		$default_cat = $this->catDAO->getDefault();
-
 		$return = null;
 		$key = $google_compliant ? 'htmlUrl' : 'feedUrl';
 		$url = $origin[$key];
@@ -438,13 +497,13 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		try {
 			// Create a Feed object and add it in database.
 			$feed = new FreshRSS_Feed($url);
-			$feed->_category($default_cat->id());
+			$feed->_category(FreshRSS_CategoryDAO::defaultCategoryId);
 			$feed->_name($name);
 			$feed->_website($website);
 
 			// Call the extension hook
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				// addFeedObject checks if feed is already in DB so nothing else to
 				// check here.
 				$id = $this->feedDAO->addFeedObject($feed);
@@ -455,32 +514,31 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 				}
 			}
 		} catch (FreshRSS_Feed_Exception $e) {
-			Minz_Log::warning($e->getMessage());
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during JSON feed import: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
 		}
 
 		return $return;
 	}
 
-	/**
-	 * This action handles export action.
-	 *
-	 * This action must be reached by a POST request.
-	 *
-	 * Parameters are:
-	 *   - export_opml (default: false)
-	 *   - export_starred (default: false)
-	 *   - export_feeds (default: array()) a list of feed ids
-	 */
-	public function exportAction() {
-		if (!Minz_Request::isPost()) {
-			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
-		}
+	public function exportFile($export_opml = true, $export_starred = false, $export_feeds = array(), $maxFeedEntries = 50, $username = null) {
+		require_once(LIB_PATH . '/lib_opml.php');
 
-		$this->view->_useLayout(false);
+		$this->catDAO = new FreshRSS_CategoryDAO($username);
+		$this->entryDAO = FreshRSS_Factory::createEntryDao($username);
+		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
+
+		if ($export_feeds === true) {
+			//All feeds
+			$export_feeds = $this->feedDAO->listFeedsIds();
+		}
+		if (!is_array($export_feeds)) {
+			$export_feeds = array();
+		}
 
-		$export_opml = Minz_Request::param('export_opml', false);
-		$export_starred = Minz_Request::param('export_starred', false);
-		$export_feeds = Minz_Request::param('export_feeds', array());
 		$day = date('Y-m-d');
 
 		$export_files = array();
@@ -497,26 +555,57 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			if ($feed) {
 				$filename = "feed_${day}_" . $feed->category() . '_'
 				          . $feed->id() . '.json';
-				$export_files[$filename] = $this->generateEntries('feed', $feed);
+				$export_files[$filename] = $this->generateEntries('feed', $feed, $maxFeedEntries);
 			}
 		}
 
 		$nb_files = count($export_files);
 		if ($nb_files > 1) {
-			// If there are more than 1 file to export, we need a zip archive.
+			// If there are more than 1 file to export, we need a ZIP archive.
 			try {
-				$this->exportZip($export_files);
+				$this->sendZip($export_files);
 			} catch (Exception $e) {
-				# Oops, there is no Zip extension!
-				Minz_Request::bad(_t('feedback.import_export.export_no_zip_extension'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_ZipMissing_Exception($e);
 			}
 		} elseif ($nb_files === 1) {
 			// Only one file? Guess its type and export it.
 			$filename = key($export_files);
-			$type = $this->guessFileType($filename);
-			$this->exportFile('freshrss_' . $filename, $export_files[$filename], $type);
-		} else {
+			$type = self::guessFileType($filename);
+			$this->sendFile('freshrss_' . $filename, $export_files[$filename], $type);
+		}
+		return $nb_files;
+	}
+
+	/**
+	 * This action handles export action.
+	 *
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameters are:
+	 *   - export_opml (default: false)
+	 *   - export_starred (default: false)
+	 *   - export_feeds (default: array()) a list of feed ids
+	 */
+	public function exportAction() {
+		if (!Minz_Request::isPost()) {
+			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
+		}
+		$this->view->_useLayout(false);
+
+		$nb_files = 0;
+		try {
+			$nb_files = $this->exportFile(
+					Minz_Request::param('export_opml', false),
+					Minz_Request::param('export_starred', false),
+					Minz_Request::param('export_feeds', array())
+				);
+		} catch (FreshRSS_ZipMissing_Exception $zme) {
+			# Oops, there is no ZIP extension!
+			Minz_Request::bad(_t('feedback.import_export.export_no_zip_extension'),
+			                  array('c' => 'importExport', 'a' => 'index'));
+		}
+
+		if ($nb_files < 1) {
 			// Nothing to do...
 			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
 		}
@@ -545,7 +634,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param FreshRSS_Feed $feed feed of which we want to get entries.
 	 * @return string the JSON file content.
 	 */
-	private function generateEntries($type, $feed = NULL) {
+	private function generateEntries($type, $feed = NULL, $maxFeedEntries = 50) {
 		$this->view->categories = $this->catDAO->listCategories();
 
 		if ($type == 'starred') {
@@ -555,12 +644,12 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$this->view->entries = $this->entryDAO->listWhere(
 				's', '', FreshRSS_Entry::STATE_ALL, 'ASC', $unread_fav['all']
 			);
-		} elseif ($type == 'feed' && !is_null($feed)) {
+		} elseif ($type === 'feed' && $feed != null) {
 			$this->view->list_title = _t('sub.import_export.feed_list', $feed->name());
 			$this->view->type = 'feed/' . $feed->id();
 			$this->view->entries = $this->entryDAO->listWhere(
 				'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC',
-				FreshRSS_Context::$user_conf->posts_per_page
+				$maxFeedEntries
 			);
 			$this->view->feed = $feed;
 		}
@@ -574,7 +663,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param array $files list of files where key is filename and value the content.
 	 * @throws Exception if Zip extension is not loaded.
 	 */
-	private function exportZip($files) {
+	private function sendZip($files) {
 		if (!extension_loaded('zip')) {
 			throw new Exception();
 		}
@@ -606,7 +695,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param string $type the file type (opml, json_feed or json_starred).
 	 *                     If equals to unknown, nothing happens.
 	 */
-	private function exportFile($filename, $content, $type) {
+	private function sendFile($filename, $content, $type) {
 		if ($type === 'unknown') {
 			return;
 		}

+ 84 - 77
app/Controllers/userController.php

@@ -24,6 +24,16 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		}
 	}
 
+	public static function hashPassword($passwordPlain) {
+		if (!function_exists('password_hash')) {
+			include_once(LIB_PATH . '/password_compat.php');
+		}
+		$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
+		$passwordPlain = '';
+		$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+		return $passwordHash == '' ? '' : $passwordHash;
+	}
+
 	/**
 	 * This action displays the user profile page.
 	 */
@@ -41,12 +51,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			if ($passwordPlain != '') {
 				Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
 				$_POST['newPasswordPlain'] = '';
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				FreshRSS_Context::$user_conf->passwordHash = $passwordHash;
 			}
@@ -54,12 +59,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 
 			$passwordPlain = Minz_Request::param('apiPasswordPlain', '', true);
 			if ($passwordPlain != '') {
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				FreshRSS_Context::$user_conf->apiPasswordHash = $passwordHash;
 			}
@@ -99,6 +99,50 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		$this->view->size_user = $entryDAO->size();
 	}
 
+	public static function createUser($new_user_name, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
+		if (!is_array($userConfig)) {
+			$userConfig = array();
+		}
+
+		$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
+
+		if ($ok) {
+			$languages = Minz_Translate::availableLanguages();
+			if (empty($userConfig['language']) || !in_array($userConfig['language'], $languages)) {
+				$userConfig['language'] = 'en';
+			}
+
+			$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()));	//Not an existing user, case-insensitive
+
+			$configPath = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
+			$ok &= !file_exists($configPath);
+		}
+		if ($ok) {
+			$passwordHash = '';
+			if ($passwordPlain != '') {
+				$passwordHash = self::hashPassword($passwordPlain);
+				$ok &= ($passwordHash != '');
+			}
+
+			$apiPasswordHash = '';
+			if ($apiPasswordPlain != '') {
+				$apiPasswordHash = self::hashPassword($apiPasswordPlain);
+				$ok &= ($apiPasswordHash != '');
+			}
+		}
+		if ($ok) {
+			mkdir(join_path(DATA_PATH, 'users', $new_user_name));
+			$userConfig['passwordHash'] = $passwordHash;
+			$userConfig['apiPasswordHash'] = $apiPasswordHash;
+			$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->createUser($new_user_name, $userConfig['language'], $insertDefaultFeeds);
+		}
+		return $ok;
+	}
+
 	/**
 	 * This action creates a new user.
 	 *
@@ -116,57 +160,13 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				FreshRSS_Auth::hasAccess('admin') ||
 				!max_registrations_reached()
 		)) {
-			$db = FreshRSS_Context::$system_conf->db;
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
-
-			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
-			$languages = Minz_Translate::availableLanguages();
-			if (!in_array($new_user_language, $languages)) {
-				$new_user_language = FreshRSS_Context::$user_conf->language;
-			}
-
 			$new_user_name = Minz_Request::param('new_user_name');
-			$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
-
-			if ($ok) {
-				$default_user = FreshRSS_Context::$system_conf->default_user;
-				$ok &= (strcasecmp($new_user_name, $default_user) !== 0);	//It is forbidden to alter the default user
-
-				$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()));	//Not an existing user, case-insensitive
+			$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
+			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
 
-				$configPath = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
-				$ok &= !file_exists($configPath);
-			}
-			if ($ok) {
-				$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
-				$passwordHash = '';
-				if ($passwordPlain != '') {
-					Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
-					$_POST['new_user_passwordPlain'] = '';
-					if (!function_exists('password_hash')) {
-						include_once(LIB_PATH . '/password_compat.php');
-					}
-					$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-					$passwordPlain = '';
-					$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
-					$ok &= ($passwordHash != '');
-				}
-				if (empty($passwordHash)) {
-					$passwordHash = '';
-				}
-			}
-			if ($ok) {
-				mkdir(join_path(DATA_PATH, 'users', $new_user_name));
-				$config_array = array(
-					'language' => $new_user_language,
-					'passwordHash' => $passwordHash,
-				);
-				$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($config_array, true) . ';') !== false);
-			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->createUser($new_user_name, $new_user_language);
-			}
+			$ok = self::createUser($new_user_name, $passwordPlain, '', array('language' => $new_user_language));
+			Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
+			$_POST['new_user_passwordPlain'] = '';
 			invalidateHttpCache();
 
 			$notif = array(
@@ -183,6 +183,27 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		Minz_Request::forward($redirect_url, true);
 	}
 
+	public static function deleteUser($username) {
+		$db = FreshRSS_Context::$system_conf->db;
+		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+
+		$ok = ctype_alnum($username);
+		if ($ok) {
+			$default_user = FreshRSS_Context::$system_conf->default_user;
+			$ok &= (strcasecmp($username, $default_user) !== 0);	//It is forbidden to delete the default user
+		}
+		$user_data = join_path(DATA_PATH, 'users', $username);
+		if ($ok) {
+			$ok &= is_dir($user_data);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->deleteUser($username);
+			$ok &= recursive_unlink($user_data);
+		}
+		return $ok;
+	}
+
 	/**
 	 * This action delete an existing user.
 	 *
@@ -204,16 +225,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				FreshRSS_Auth::hasAccess('admin') ||
 				$self_deletion
 		)) {
-			$db = FreshRSS_Context::$system_conf->db;
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
-
-			$ok = ctype_alnum($username);
-			$user_data = join_path(DATA_PATH, 'users', $username);
-
-			if ($ok) {
-				$default_user = FreshRSS_Context::$system_conf->default_user;
-				$ok &= (strcasecmp($username, $default_user) !== 0);	//It is forbidden to delete the default user
-			}
+			$ok = true;
 			if ($ok && $self_deletion) {
 				// We check the password if it's a self-destruction
 				$nonce = Minz_Session::param('nonce');
@@ -225,12 +237,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				);
 			}
 			if ($ok) {
-				$ok &= is_dir($user_data);
-			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->deleteUser($username);
-				$ok &= recursive_unlink($user_data);
+				$ok &= self::deleteUser($username);
 			}
 			if ($ok && $self_deletion) {
 				FreshRSS_Auth::removeAccess();

+ 14 - 0
app/Exceptions/ZipException.php

@@ -0,0 +1,14 @@
+<?php
+
+class FreshRSS_Zip_Exception extends Exception {
+	private $zipErrorCode = 0;
+	
+	public function __construct($zipErrorCode) {
+		parent::__construct('ZIP error! ' . $url, 2141);
+		$this->zipErrorCode = $zipErrorCode;
+	}
+
+	public function zipErrorCode() {
+		return $this->zipErrorCode;
+	}
+}

+ 4 - 0
app/Exceptions/ZipMissingException.php

@@ -0,0 +1,4 @@
+<?php
+
+class FreshRSS_ZipMissing_Exception extends Exception {
+}

+ 7 - 4
app/Models/CategoryDAO.php

@@ -1,6 +1,9 @@
 <?php
 
 class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
+
+	const defaultCategoryId = 1;
+
 	public function addCategory($valuesTmp) {
 		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)';
 		$stm = $this->bd->prepare($sql);
@@ -50,7 +53,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 
 	public function deleteCategory($id) {
-		if ($id <= 1) {
+		if ($id <= self::defaultCategoryId) {
 			return false;
 		}
 		$sql = 'DELETE FROM `' . $this->prefix . 'category` WHERE id=?';
@@ -120,7 +123,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 
 	public function getDefault() {
-		$sql = 'SELECT * FROM `' . $this->prefix . 'category` WHERE id=1';
+		$sql = 'SELECT * FROM `' . $this->prefix . 'category` WHERE id=' . self::defaultCategoryId;
 		$stm = $this->bd->prepare($sql);
 
 		$stm->execute();
@@ -134,11 +137,11 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 		}
 	}
 	public function checkDefault() {
-		$def_cat = $this->searchById(1);
+		$def_cat = $this->searchById(self::defaultCategoryId);
 
 		if ($def_cat == null) {
 			$cat = new FreshRSS_Category(_t('gen.short.default_category'));
-			$cat->_id(1);
+			$cat->_id(self::defaultCategoryId);
 
 			$values = array(
 				'id' => $cat->id(),

+ 1 - 1
app/Models/Context.php

@@ -37,7 +37,7 @@ class FreshRSS_Context {
 	public static $id_max = '';
 	public static $sinceHours = 0;
 
-	public static $isCron = false;
+	public static $isCli = false;
 
 	/**
 	 * Initialize the context.

+ 1 - 1
app/Models/Feed.php

@@ -141,7 +141,7 @@ class FreshRSS_Feed extends Minz_Model {
 		if (!file_exists($txt)) {
 			file_put_contents($txt, $url);
 		}
-		if (FreshRSS_Context::$isCron) {
+		if (FreshRSS_Context::$isCli) {
 			$ico = $favicons_dir . $this->hash() . '.ico';
 			$ico_mtime = @filemtime($ico);
 			$txt_mtime = @filemtime($txt);

+ 7 - 0
app/Models/FeedDAO.php

@@ -212,6 +212,13 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 	}
 
+	public function listFeedsIds() {
+		$sql = 'SELECT id FROM `' . $this->prefix . 'feed`';
+		$stm = $this->bd->prepare($sql);
+		$stm->execute();
+		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+	}
+
 	public function listFeeds() {
 		$sql = 'SELECT * FROM `' . $this->prefix . 'feed` ORDER BY name';
 		$stm = $this->bd->prepare($sql);

+ 17 - 1
app/Models/UserDAO.php

@@ -1,7 +1,7 @@
 <?php
 
 class FreshRSS_UserDAO extends Minz_ModelPdo {
-	public function createUser($username, $new_user_language) {
+	public function createUser($username, $new_user_language, $insertDefaultFeeds = true) {
 		$db = FreshRSS_Context::$system_conf->db;
 		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
 
@@ -28,6 +28,22 @@ class FreshRSS_UserDAO extends Minz_ModelPdo {
 					}
 				}
 			}
+			if ($insertDefaultFeeds) {
+				if (defined('SQL_INSERT_FEEDS')) {	//E.g. MySQL
+					$sql = sprintf(SQL_INSERT_FEEDS, $bd_prefix_user);
+					$stm = $userPDO->bd->prepare($sql);
+					$ok &= $stm && $stm->execute();
+				} else {	//E.g. SQLite
+					global $SQL_INSERT_FEEDS;
+					if (is_array($SQL_INSERT_FEEDS)) {
+						foreach ($SQL_INSERT_FEEDS as $instruction) {
+							$sql = sprintf($instruction, $bd_prefix_user);
+							$stm = $userPDO->bd->prepare($sql);
+							$ok &= ($stm && $stm->execute());
+						}
+					}
+				}
+			}
 		} catch (Exception $e) {
 			Minz_Log::error('Error while creating user: ' . $e->getMessage());
 		}

+ 3 - 0
app/SQL/install.sql.mysql.php

@@ -59,6 +59,9 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 ENGINE = INNODB;
 
 INSERT IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");
+');
+
+define('SQL_INSERT_FEEDS', '
 INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("http://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "http://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);
 INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS @ GitHub", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);
 ');

+ 4 - 0
app/SQL/install.sql.pgsql.php

@@ -52,6 +52,10 @@ $SQL_CREATE_TABLES = array(
 'CREATE INDEX %1$sentry_lastSeen_index ON "%1$sentry" ("lastSeen");',
 
 'INSERT INTO "%1$scategory" (name) SELECT \'%2$s\' WHERE NOT EXISTS (SELECT id FROM "%1$scategory" WHERE id = 1);',
+);
+
+global $SQL_INSERT_FEEDS;
+$SQL_INSERT_FEEDS = array(
 'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'http://freshrss.org/feeds/all.atom.xml\', 1, \'FreshRSS.org\', \'http://freshrss.org/\', \'FreshRSS, a free, self-hostable aggregator…\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'http://freshrss.org/feeds/all.atom.xml\');',
 'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'https://github.com/FreshRSS/FreshRSS/releases.atom\', 1, \'FreshRSS @ GitHub\', \'https://github.com/FreshRSS/FreshRSS/\', \'FreshRSS releases @ GitHub\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'https://github.com/FreshRSS/FreshRSS/releases.atom\');',
 );

+ 4 - 0
app/SQL/install.sql.sqlite.php

@@ -55,6 +55,10 @@ $SQL_CREATE_TABLES = array(
 'CREATE INDEX IF NOT EXISTS entry_lastSeen_index ON `entry`(`lastSeen`);',	//v1.1.1
 
 'INSERT OR IGNORE INTO `category` (id, name) VALUES(1, "%2$s");',
+);
+
+global $SQL_INSERT_FEEDS;
+$SQL_INSERT_FEEDS = array(
 'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl) VALUES("http://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "http://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);',
 'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS releases", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);',
 );

+ 2 - 2
app/actualize_script.php

@@ -28,13 +28,13 @@ $app = new FreshRSS();
 
 $system_conf = Minz_Configuration::get('system');
 $system_conf->auth_type = 'none';  // avoid necessity to be logged in (not saved!)
-FreshRSS_Context::$isCron = true;
+FreshRSS_Context::$isCli = true;
 
 // Create the list of users to actualize.
 // Users are processed in a random order but always start with admin
 $users = listUsers();
 shuffle($users);
-if ($system_conf->default_user !== ''){
+if ($system_conf->default_user !== '') {
 	array_unshift($users, $system_conf->default_user);
 	$users = array_unique($users);
 }

+ 3 - 3
app/i18n/cz/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s neexistuje',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Na serveru není naistalována podpora zip. Zkuste prosím exportovat soubory jeden po druhém.',
+		'export_no_zip_extension' => 'Na serveru není naistalována podpora ZIP. Zkuste prosím exportovat soubory jeden po druhém.',
 		'feeds_imported' => 'Vaše kanály byly naimportovány a nyní budou aktualizovány',
 		'feeds_imported_with_errors' => 'Vaše kanály byly naimportovány, došlo ale k nějakým chybám',
 		'file_cannot_be_uploaded' => 'Soubor nelze nahrát!',
-		'no_zip_extension' => 'Na serveru není naistalována podpora zip.',
-		'zip_error' => 'Během importu zip souboru došlo k chybě.',
+		'no_zip_extension' => 'Na serveru není naistalována podpora ZIP.',
+		'zip_error' => 'Během importu ZIP souboru došlo k chybě.',
 	),
 	'sub' => array(
 		'actualize' => 'Aktualizovat',

+ 2 - 2
app/i18n/cz/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Exportovat seznam kanálů (OPML)',
 		'export_starred' => 'Exportovat oblíbené',
 		'feed_list' => 'Seznam %s článků',
-		'file_to_import' => 'Soubor k importu<br />(OPML, Json nebo Zip)',
-		'file_to_import_no_zip' => 'Soubor k importu<br />(OPML nebo Json)',
+		'file_to_import' => 'Soubor k importu<br />(OPML, JSON nebo ZIP)',
+		'file_to_import_no_zip' => 'Soubor k importu<br />(OPML nebo JSON)',
 		'import' => 'Import',
 		'starred_list' => 'Seznam oblíbených článků',
 		'title' => 'Import / export',

+ 3 - 3
app/i18n/de/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s existiert nicht',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Die Zip-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie die Dateien eine nach der anderen zu exportieren.',
+		'export_no_zip_extension' => 'Die ZIP-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie die Dateien eine nach der anderen zu exportieren.',
 		'feeds_imported' => 'Ihre Feeds sind importiert worden und werden jetzt aktualisiert',
 		'feeds_imported_with_errors' => 'Ihre Feeds sind importiert worden, aber es traten einige Fehler auf',
 		'file_cannot_be_uploaded' => 'Die Datei kann nicht hochgeladen werden!',
-		'no_zip_extension' => 'Die Zip-Erweiterung ist auf Ihrem Server nicht vorhanden.',
-		'zip_error' => 'Ein Fehler trat während des Zip-Imports auf.',
+		'no_zip_extension' => 'Die ZIP-Erweiterung ist auf Ihrem Server nicht vorhanden.',
+		'zip_error' => 'Ein Fehler trat während des ZIP-Imports auf.',
 	),
 	'sub' => array(
 		'actualize' => 'Aktualisieren',

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

@@ -44,7 +44,7 @@ return array(
 		'export_opml' => 'Liste der Feeds exportieren (OPML)',
 		'export_starred' => 'Ihre Favoriten exportieren',
 		'feed_list' => 'Liste von %s Artikeln',
-		'file_to_import' => 'Zu importierende Datei<br />(OPML, JSON oder Zip)',
+		'file_to_import' => 'Zu importierende Datei<br />(OPML, JSON oder ZIP)',
 		'file_to_import_no_zip' => 'Zu importierende Datei<br />(OPML oder JSON)',
 		'import' => 'Importieren',
 		'starred_list' => 'Liste der Lieblingsartikel',

+ 3 - 3
app/i18n/en/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'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.',
+		'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',
 		'feeds_imported_with_errors' => 'Your feeds have been imported but some errors occurred',
 		'file_cannot_be_uploaded' => 'File cannot be uploaded!',
-		'no_zip_extension' => 'Zip extension is not present on your server.',
-		'zip_error' => 'An error occured during Zip import.',
+		'no_zip_extension' => 'ZIP extension is not present on your server.',
+		'zip_error' => 'An error occured during ZIP import.',
 	),
 	'sub' => array(
 		'actualize' => 'Actualise',

+ 2 - 2
app/i18n/en/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Export list of feeds (OPML)',
 		'export_starred' => 'Export your favourites',
 		'feed_list' => 'List of %s articles',
-		'file_to_import' => 'File to import<br />(OPML, Json or Zip)',
-		'file_to_import_no_zip' => 'File to import<br />(OPML or Json)',
+		'file_to_import' => 'File to import<br />(OPML, JSON or ZIP)',
+		'file_to_import_no_zip' => 'File to import<br />(OPML or JSON)',
 		'import' => 'Import',
 		'starred_list' => 'List of favourite articles',
 		'title' => 'Import / export',

+ 3 - 3
app/i18n/fr/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'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.',
+		'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.',
 		'feeds_imported_with_errors' => 'Vos flux ont été importés mais des erreurs sont survenues.',
 		'file_cannot_be_uploaded' => 'Le fichier ne peut pas être téléchargé !',
-		'no_zip_extension' => 'L’extension Zip n’est pas présente sur votre serveur.',
-		'zip_error' => 'Une erreur est survenue durant l’import du fichier Zip.',
+		'no_zip_extension' => 'L’extension ZIP n’est pas présente sur votre serveur.',
+		'zip_error' => 'Une erreur est survenue durant l’import du fichier ZIP.',
 	),
 	'sub' => array(
 		'actualize' => 'Actualiser',

+ 2 - 2
app/i18n/fr/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Exporter la liste des flux (OPML)',
 		'export_starred' => 'Exporter les favoris',
 		'feed_list' => 'Liste des articles de %s',
-		'file_to_import' => 'Fichier à importer<br />(OPML, Json ou Zip)',
-		'file_to_import_no_zip' => 'Fichier à importer<br />(OPML ou Json)',
+		'file_to_import' => 'Fichier à importer<br />(OPML, JSON ou ZIP)',
+		'file_to_import_no_zip' => 'Fichier à importer<br />(OPML ou JSON)',
 		'import' => 'Importer',
 		'starred_list' => 'Liste des articles favoris',
 		'title' => 'Importer / exporter',

+ 3 - 3
app/i18n/it/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s non disponibile',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Estensione Zip non presente sul server. Per favore esporta i files singolarmente.',
+		'export_no_zip_extension' => 'Estensione ZIP non presente sul server. Per favore esporta i files singolarmente.',
 		'feeds_imported' => 'I tuoi feed sono stati importati e saranno aggiornati',
 		'feeds_imported_with_errors' => 'I tuoi feeds sono stati importati ma si sono verificati alcuni errori',
 		'file_cannot_be_uploaded' => 'Il file non può essere caricato!',
-		'no_zip_extension' => 'Estensione Zip non presente sul server.',
-		'zip_error' => 'Si è verificato un errore importando il file Zip',
+		'no_zip_extension' => 'Estensione ZIP non presente sul server.',
+		'zip_error' => 'Si è verificato un errore importando il file ZIP',
 	),
 	'sub' => array(
 		'actualize' => 'Aggiorna',

+ 2 - 2
app/i18n/it/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Esporta tutta la lista dei feed (OPML)',
 		'export_starred' => 'Esporta i tuoi preferiti',
 		'feed_list' => 'Elenco di %s articoli',
-		'file_to_import' => 'File da importare<br />(OPML, Json o Zip)',
-		'file_to_import_no_zip' => 'File da importare<br />(OPML o Json)',
+		'file_to_import' => 'File da importare<br />(OPML, JSON o ZIP)',
+		'file_to_import_no_zip' => 'File da importare<br />(OPML o JSON)',
 		'import' => 'Importa',
 		'starred_list' => 'Elenco articoli preferiti',
 		'title' => 'Importa / esporta',

+ 3 - 3
app/i18n/nl/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s bestaat niet',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Zip uitbreiding is niet aanwezig op uw server. Exporteer a.u.b. uw bestanden één voor één.',
+		'export_no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server. Exporteer a.u.b. uw bestanden één voor één.',
 		'feeds_imported' => 'Uw feeds zijn geimporteerd en worden nu vernieuwd',
 		'feeds_imported_with_errors' => 'Uw feeds zijn geimporteerd maar er zijn enige fouten opgetreden',
 		'file_cannot_be_uploaded' => 'Bestand kan niet worden verzonden!',
-		'no_zip_extension' => 'Zip uitbreiding is niet aanwezig op uw server.',
-		'zip_error' => 'Er is een fout opgetreden tijdens het imporeren van het Zip bestand.',
+		'no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server.',
+		'zip_error' => 'Er is een fout opgetreden tijdens het imporeren van het ZIP bestand.',
 	),
 	'sub' => array(
 		'actualize' => 'Actualiseren',

+ 2 - 2
app/i18n/nl/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Exporteer lijst van feeds (OPML)',
 		'export_starred' => 'Exporteer je fovorieten',
 		'feed_list' => 'Lijst van %s artikelen',
-		'file_to_import' => 'Bestand om te importeren<br />(OPML, Json of Zip)',
-		'file_to_import_no_zip' => 'Bestand om te importeren<br />(OPML of Json)',
+		'file_to_import' => 'Bestand om te importeren<br />(OPML, JSON of ZIP)',
+		'file_to_import_no_zip' => 'Bestand om te importeren<br />(OPML of JSON)',
 		'import' => 'Importeer',
 		'starred_list' => 'Lijst van favoriete artikelen',
 		'title' => 'Importeren / exporteren',

+ 3 - 3
app/i18n/ru/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'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.',
+		'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',
 		'feeds_imported_with_errors' => 'Your feeds have been imported but some errors occurred',
 		'file_cannot_be_uploaded' => 'File cannot be uploaded!',
-		'no_zip_extension' => 'Zip extension is not present on your server.',
-		'zip_error' => 'An error occured during Zip import.',
+		'no_zip_extension' => 'ZIP extension is not present on your server.',
+		'zip_error' => 'An error occured during ZIP import.',
 	),
 	'sub' => array(
 		'actualize' => 'Actualise',

+ 2 - 2
app/i18n/ru/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Export list of feeds (OPML)',
 		'export_starred' => 'Export your favourites',
 		'feed_list' => 'List of %s articles',
-		'file_to_import' => 'File to import<br />(OPML, Json or Zip)',
-		'file_to_import_no_zip' => 'File to import<br />(OPML or Json)',
+		'file_to_import' => 'File to import<br />(OPML, JSON or ZIP)',
+		'file_to_import_no_zip' => 'File to import<br />(OPML or JSON)',
 		'import' => 'Import',
 		'starred_list' => 'List of favourite articles',
 		'title' => 'Import / export',

+ 3 - 3
app/i18n/tr/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s bulunmamaktadır',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Zip eklentisi mevcut sunucunuzda yer almıyor. Lütfen başka dosya formatında dışarı aktarmayı deneyin.',
+		'export_no_zip_extension' => 'ZIP eklentisi mevcut sunucunuzda yer almıyor. Lütfen başka dosya formatında dışarı aktarmayı deneyin.',
 		'feeds_imported' => 'Akışlarınız içe aktarıldı ve şimdi güncellenecek',
 		'feeds_imported_with_errors' => 'Akışlarınız içeri aktarıldı ama bazı hatalar meydana geldi',
 		'file_cannot_be_uploaded' => 'Dosya yüklenemedi!',
-		'no_zip_extension' => 'Zip eklentisi mevcut sunucunuzda yer almıyor.',
-		'zip_error' => 'Zip içe aktarımı sırasında hata meydana geldi.',
+		'no_zip_extension' => 'ZIP eklentisi mevcut sunucunuzda yer almıyor.',
+		'zip_error' => 'ZIP içe aktarımı sırasında hata meydana geldi.',
 	),
 	'sub' => array(
 		'actualize' => 'Güncelleme',

+ 2 - 2
app/i18n/tr/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Akış listesini dışarı aktar (OPML)',
 		'export_starred' => 'Favorileri dışarı aktar',
 		'feed_list' => '%s makalenin listesi',
-		'file_to_import' => 'Dosyadan içe aktar<br />(OPML, Json or Zip)',
-		'file_to_import_no_zip' => 'Dosyadan içe aktar<br />(OPML or Json)',
+		'file_to_import' => 'Dosyadan içe aktar<br />(OPML, JSON or ZIP)',
+		'file_to_import_no_zip' => 'Dosyadan içe aktar<br />(OPML or JSON)',
 		'import' => 'İçe aktar',
 		'starred_list' => 'Favori makaleleirn listesi',
 		'title' => 'İçe / dışa aktar',

+ 44 - 133
app/install.php

@@ -4,15 +4,12 @@ if (function_exists('opcache_reset')) {
 }
 header("Content-Security-Policy: default-src 'self'");
 
-define('BCRYPT_COST', 9);
+require(LIB_PATH . '/lib_install.php');
 
 session_name('FreshRSS');
 session_set_cookie_params(0, dirname(empty($_SERVER['REQUEST_URI']) ? '/' : dirname($_SERVER['REQUEST_URI'])), null, false, true);
 session_start();
 
-Minz_Configuration::register('default_system', join_path(DATA_PATH, 'config.default.php'));
-Minz_Configuration::register('default_user', join_path(USERS_PATH, '_', 'config.default.php'));
-
 if (isset($_GET['step'])) {
 	define('STEP',(int)$_GET['step']);
 } else {
@@ -26,13 +23,13 @@ if (STEP === 3 && isset($_POST['type'])) {
 if (isset($_SESSION['bd_type'])) {
 	switch ($_SESSION['bd_type']) {
 	case 'mysql':
-		include(APP_PATH . '/SQL/install.sql.mysql.php');
+		include_once(APP_PATH . '/SQL/install.sql.mysql.php');
 		break;
 	case 'sqlite':
-		include(APP_PATH . '/SQL/install.sql.sqlite.php');
+		include_once(APP_PATH . '/SQL/install.sql.sqlite.php');
 		break;
 	case 'pgsql':
-		include(APP_PATH . '/SQL/install.sql.pgsql.php');
+		include_once(APP_PATH . '/SQL/install.sql.pgsql.php');
 		break;
 	}
 }
@@ -131,12 +128,7 @@ function saveStep2() {
 
 		$password_plain = param('passwordPlain', false);
 		if ($password_plain !== false && cryptAvailable()) {
-			if (!function_exists('password_hash')) {
-				include_once(LIB_PATH . '/password_compat.php');
-			}
-			$passwordHash = password_hash($password_plain, PASSWORD_BCRYPT, array('cost' => BCRYPT_COST));
-			$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
-			$_SESSION['passwordHash'] = $passwordHash;
+			$_SESSION['passwordHash'] = FreshRSS_user_Controller::hashPassword($password_plain);
 		}
 
 		if (empty($_SESSION['old_entries']) ||
@@ -149,7 +141,7 @@ function saveStep2() {
 			return false;
 		}
 
-		$_SESSION['salt'] = sha1(uniqid(mt_rand(), true).implode('', stat(__FILE__)));
+		$_SESSION['salt'] = generateSalt();
 		if ((!ctype_digit($_SESSION['old_entries'])) ||($_SESSION['old_entries'] < 1)) {
 			$_SESSION['old_entries'] = $user_default_config->old_entries;
 		}
@@ -171,7 +163,7 @@ function saveStep2() {
 
 		recursive_unlink($user_dir);
 		mkdir($user_dir);
-		file_put_contents($user_config_path, "<?php\n return " . var_export($config_array, true) . ';');
+		file_put_contents($user_config_path, "<?php\n return " . var_export($config_array, true) . ";\n");
 
 		header('Location: index.php?step=3');
 	}
@@ -225,35 +217,30 @@ function saveStep3() {
 		);
 
 		@unlink(join_path(DATA_PATH, 'config.php'));	//To avoid access-rights problems
-		file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config_array, true) . ';');
+		file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config_array, true) . ";\n");
 
-		$res = checkBD();
+		$config_array['db']['default_user'] = $config_array['default_user'];
+		$config_array['db']['prefix_user'] = $_SESSION['bd_prefix_user'];
+		$ok = checkDb($config_array['db']) && checkDbUser($config_array['db']);
+		if (!$ok) {
+			@unlink(join_path(DATA_PATH, 'config.php'));
+		}
 
-		if ($res) {
+		if ($ok) {
 			$_SESSION['bd_error'] = '';
 			header('Location: index.php?step=4');
-		} elseif (empty($_SESSION['bd_error'])) {
-			$_SESSION['bd_error'] = 'Unknown error!';
+		} else {
+			$_SESSION['bd_error'] = empty($config_array['db']['bd_error']) ? 'Unknown error!' : $config_array['db']['bd_error'];
 		}
 	}
 	invalidateHttpCache();
 }
 
-function deleteInstall() {
-	$res = unlink(join_path(DATA_PATH, 'do-install.txt'));
-
-	if (!$res) {
-		return false;
-	}
-
-	header('Location: index.php');
-}
-
 
 /*** VÉRIFICATIONS ***/
 function checkStep() {
 	$s0 = checkStep0();
-	$s1 = checkStep1();
+	$s1 = checkRequirements();
 	$s2 = checkStep2();
 	$s3 = checkStep3();
 	if (STEP > 0 && $s0['all'] != 'ok') {
@@ -279,49 +266,6 @@ function checkStep0() {
 	);
 }
 
-function checkStep1() {
-	$php = version_compare(PHP_VERSION, '5.3.3') >= 0;
-	$minz = file_exists(join_path(LIB_PATH, 'Minz'));
-	$curl = extension_loaded('curl');
-	$pdo_mysql = extension_loaded('pdo_mysql');
-	$pdo_sqlite = extension_loaded('pdo_sqlite');
-	$pdo_pgsql = extension_loaded('pdo_pgsql');
-	$pdo = $pdo_mysql || $pdo_sqlite || $pdo_pgsql;
-	$pcre = extension_loaded('pcre');
-	$ctype = extension_loaded('ctype');
-	$dom = class_exists('DOMDocument');
-	$xml = function_exists('xml_parser_create');
-	$json = function_exists('json_encode');
-	$data = DATA_PATH && is_writable(DATA_PATH);
-	$cache = CACHE_PATH && is_writable(CACHE_PATH);
-	$users = USERS_PATH && is_writable(USERS_PATH);
-	$favicons = is_writable(join_path(DATA_PATH, 'favicons'));
-	$http_referer = is_referer_from_same_domain();
-
-	return array(
-		'php' => $php ? 'ok' : 'ko',
-		'minz' => $minz ? 'ok' : 'ko',
-		'curl' => $curl ? 'ok' : 'ko',
-		'pdo-mysql' => $pdo_mysql ? 'ok' : 'ko',
-		'pdo-sqlite' => $pdo_sqlite ? 'ok' : 'ko',
-		'pdo-pgsql' => $pdo_pgsql ? 'ok' : 'ko',
-		'pdo' => $pdo ? 'ok' : 'ko',
-		'pcre' => $pcre ? 'ok' : 'ko',
-		'ctype' => $ctype ? 'ok' : 'ko',
-		'dom' => $dom ? 'ok' : 'ko',
-		'xml' => $xml ? 'ok' : 'ko',
-		'json' => $json ? 'ok' : 'ko',
-		'data' => $data ? 'ok' : 'ko',
-		'cache' => $cache ? 'ok' : 'ko',
-		'users' => $users ? 'ok' : 'ko',
-		'favicons' => $favicons ? 'ok' : 'ko',
-		'http_referer' => $http_referer ? 'ok' : 'ko',
-		'all' => $php && $minz && $curl && $pdo && $pcre && $ctype && $dom && $xml &&
-		         $data && $cache && $users && $favicons && $http_referer ?
-		         'ok' : 'ko'
-	);
-}
-
 function freshrss_already_installed() {
 	$conf_path = join_path(DATA_PATH, 'config.php');
 	if (!file_exists($conf_path)) {
@@ -392,60 +336,15 @@ function checkStep3() {
 	);
 }
 
-function checkBD() {
+function checkDbUser(&$dbOptions) {
 	$ok = false;
-
+	$str = $dbOptions['dsn'];
+	$driver_options = $dbOptions['options'];
 	try {
-		$str = '';
-		$driver_options = null;
-		switch ($_SESSION['bd_type']) {
-		case 'mysql':
-			$driver_options = array(
-				PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4'
-			);
-
-			try {	// on ouvre une connexion juste pour créer la base si elle n'existe pas
-				$str = 'mysql:host=' . $_SESSION['bd_host'] . ';';
-				$c = new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
-				$sql = sprintf(SQL_CREATE_DB, $_SESSION['bd_base']);
-				$res = $c->query($sql);
-			} catch (PDOException $e) {
-			}
-
-			// on écrase la précédente connexion en sélectionnant la nouvelle BDD
-			$str = 'mysql:host=' . $_SESSION['bd_host'] . ';dbname=' . $_SESSION['bd_base'];
-			break;
-		case 'sqlite':
-			$str = 'sqlite:' . join_path(USERS_PATH, $_SESSION['default_user'], 'db.sqlite');
-			$driver_options = array(
-				PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-			);
-			break;
-		case 'pgsql':
-			$driver_options = array(
-				PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-			);
-
-			try {	// on ouvre une connexion juste pour créer la base si elle n'existe pas
-				$str = 'pgsql:host=' . $_SESSION['bd_host'] . ';dbname=postgres';
-				$c = new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
-				$sql = sprintf(SQL_CREATE_DB, $_SESSION['bd_base']);
-				$res = $c->query($sql);
-			} catch (PDOException $e) {
-				syslog(LOG_DEBUG, 'pgsql ' . $e->getMessage());
-			}
-
-			// on écrase la précédente connexion en sélectionnant la nouvelle BDD
-			$str = 'pgsql:host=' . $_SESSION['bd_host'] . ';dbname=' . $_SESSION['bd_base'];
-			break;
-		default:
-			return false;
-		}
-
-		$c = new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
+		$c = new PDO($str, $dbOptions['user'], $dbOptions['password'], $driver_options);
 
 		if (defined('SQL_CREATE_TABLES')) {
-			$sql = sprintf(SQL_CREATE_TABLES, $_SESSION['bd_prefix_user'], _t('gen.short.default_category'));
+			$sql = sprintf(SQL_CREATE_TABLES, $dbOptions['prefix_user'], _t('gen.short.default_category'));
 			$stm = $c->prepare($sql);
 			$ok = $stm->execute();
 		} else {
@@ -453,7 +352,22 @@ function checkBD() {
 			if (is_array($SQL_CREATE_TABLES)) {
 				$ok = true;
 				foreach ($SQL_CREATE_TABLES as $instruction) {
-					$sql = sprintf($instruction, $_SESSION['bd_prefix_user'], _t('gen.short.default_category'));
+					$sql = sprintf($instruction, $dbOptions['prefix_user'], _t('gen.short.default_category'));
+					$stm = $c->prepare($sql);
+					$ok &= $stm->execute();
+				}
+			}
+		}
+
+		if (defined('SQL_INSERT_FEEDS')) {
+			$sql = sprintf(SQL_INSERT_FEEDS, $dbOptions['prefix_user']);
+			$stm = $c->prepare($sql);
+			$ok &= $stm->execute();
+		} else {
+			global $SQL_INSERT_FEEDS;
+			if (is_array($SQL_INSERT_FEEDS)) {
+				foreach ($SQL_INSERT_FEEDS as $instruction) {
+					$sql = sprintf($instruction, $dbOptions['prefix_user']);
 					$stm = $c->prepare($sql);
 					$ok &= $stm->execute();
 				}
@@ -461,13 +375,8 @@ function checkBD() {
 		}
 	} catch (PDOException $e) {
 		$ok = false;
-		$_SESSION['bd_error'] = $e->getMessage();
-	}
-
-	if (!$ok) {
-		@unlink(join_path(DATA_PATH, 'config.php'));
+		$dbOptions['bd_error'] = $e->getMessage();
 	}
-
 	return $ok;
 }
 
@@ -510,7 +419,7 @@ function printStep0() {
 
 // @todo refactor this view with the check_install action
 function printStep1() {
-	$res = checkStep1();
+	$res = checkRequirements();
 ?>
 	<noscript><p class="alert alert-warn"><span class="alert-head"><?php echo _t('gen.short.attention'); ?></span> <?php echo _t('install.javascript_is_better'); ?></p></noscript>
 
@@ -805,7 +714,9 @@ case 3:
 case 4:
 	break;
 case 5:
-	deleteInstall();
+	if (deleteInstall()) {
+		header('Location: index.php');
+	}
 	break;
 }
 ?>

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

@@ -44,7 +44,7 @@
 						$select_args = ' size="' . min(10, count($this->feeds)) .'" multiple="multiple"';
 					}
 				?>
-				<select name="export_feeds[]"<?php echo $select_args; ?>>
+				<select name="export_feeds[]"<?php echo $select_args; ?> size="10">
 					<?php echo extension_loaded('zip') ? '' : '<option></option>'; ?>
 					<?php foreach ($this->feeds as $feed) { ?>
 					<option value="<?php echo $feed->id(); ?>"><?php echo $feed->name(); ?></option>

+ 3 - 0
cli/.htaccess

@@ -0,0 +1,3 @@
+Order	Allow,Deny
+Deny	from all
+Satisfy	all

+ 54 - 0
cli/README.md

@@ -0,0 +1,54 @@
+* Back to [main read-me](../README.md)
+
+# FreshRSS Command-Line Interface (CLI)
+
+## Note on access rights
+
+When using the command-line interface, remember that your user might not be the same as the one used by your Web server.
+This might create some access right problems.
+
+It is recommended to invoke commands using the same user as your Web server:
+
+```sh
+cd /usr/share/FreshRSS
+sudo -u www-data sh -c './cli/list-users.php'
+```
+
+In any case, when you are done with a series of commands, you should re-apply the access rights:
+
+```sh
+cd /usr/share/FreshRSS
+sudo chown -R :www-data .
+sudo chmod -R g+r .
+sudo chmod -R g+w ./data/
+```
+
+
+## Commands
+
+Options in parenthesis are optional.
+
+
+```sh
+cd /usr/share/FreshRSS
+
+./cli/do-install.php --default_user admin --auth_type form  ( --environment production --base_url https://rss.example.net/ --language en --title FreshRSS --allow_anonymous --api_enabled --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123 --db-base freshrss --db-prefix freshrss )
+# The default database is SQLite
+# Does not create the default user. Do that with ./cli/create-user.php
+
+./cli/create-user.php --user username ( --password 'password' --api-password 'api_password' --language en --email user@example.net --token 'longRandomString' --no-default-feeds )
+
+./cli/delete-user.php --user username
+
+./cli/list-users.php
+# Return a list of users, with the default/admin user first
+
+./cli/actualize-user.php --user username
+
+./cli/import-for-user.php --user username --filename /path/to/file.ext
+
+./cli/export-opml-for-user.php --user username > /path/to/file.opml.xml
+
+./cli/export-zip-for-user.php --user username ( --max-feed-entries 100 ) > /path/to/file.zip
+
+```

+ 49 - 0
cli/_cli.php

@@ -0,0 +1,49 @@
+<?php
+if (php_sapi_name() !== 'cli') {
+	die('FreshRSS error: This PHP script may only be invoked from command line!');
+}
+
+require(dirname(__FILE__) . '/../constants.php');
+require(LIB_PATH . '/lib_rss.php');
+
+Minz_Configuration::register('system',
+	DATA_PATH . '/config.php',
+	DATA_PATH . '/config.default.php');
+FreshRSS_Context::$system_conf = Minz_Configuration::get('system');
+Minz_Translate::init('en');
+
+FreshRSS_Context::$isCli = true;
+
+function fail($message) {
+	fwrite(STDERR, $message . "\n");
+	die(1);
+}
+
+function cliInitUser($username) {
+	if (!ctype_alnum($username)) {
+		fail('FreshRSS error: invalid username: ' . $username . "\n");
+	}
+
+	$usernames = listUsers();
+	if (!in_array($username, $usernames)) {
+		fail('FreshRSS error: user not found: ' . $username . "\n");
+	}
+
+	FreshRSS_Context::$user_conf = get_user_configuration($username);
+	if (FreshRSS_Context::$user_conf == null) {
+		fail('FreshRSS error: invalid configuration for user: ' . $username . "\n");
+	}
+	new Minz_ModelPdo($username);
+
+	return $username;
+}
+
+function accessRights() {
+	echo '• Remember to re-apply the appropriate access rights, such as:' , "\n",
+		"\t", 'sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/', "\n";
+}
+
+function done($ok = true) {
+	fwrite(STDERR, 'Result: ' . ($ok ? 'success' : 'fail') . "\n");
+	exit($ok ? 0 : 1);
+}

+ 23 - 0
cli/actualize-user.php

@@ -0,0 +1,23 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n");
+
+list($nbUpdatedFeeds, $feed) = FreshRSS_feed_Controller::actualizeFeed(0, '', true);
+
+echo "FreshRSS actualized $nbUpdatedFeeds feeds for $username\n";
+
+invalidateHttpCache($username);
+
+done($nbUpdatedFeeds > 0);

+ 48 - 0
cli/create-user.php

@@ -0,0 +1,48 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'password:',
+		'api-password:',
+		'language:',
+		'email:',
+		'token:',
+		'no-default-feeds',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username ( --password 'password' --api-password 'api_password'" .
+		" --language en --email user@example.net --token 'longRandomString --no-default-feeds' )");
+}
+$username = $options['user'];
+if (!ctype_alnum($username)) {
+	fail('FreshRSS error: invalid username “' . $username . '”');
+}
+
+$usernames = listUsers();
+if (preg_grep("/^$username$/i", $usernames)) {
+	fail('FreshRSS error: username already taken “' . $username . '”');
+}
+
+echo 'FreshRSS creating user “', $username, "”…\n";
+
+$ok = FreshRSS_user_Controller::createUser($username,
+	empty($options['password']) ? '' : $options['password'],
+	empty($options['api-password']) ? '' : $options['api-password'],
+	array(
+		'language' => empty($options['language']) ? '' : $options['language'],
+		'token' => empty($options['token']) ? '' : $options['token'],
+	),
+	!isset($options['no-default-feeds']));
+
+if (!$ok) {
+	fail('FreshRSS could not create user!');
+}
+
+invalidateHttpCache(FreshRSS_Context::$system_conf->default_user);
+
+accessRights();
+
+done($ok);

+ 32 - 0
cli/delete-user.php

@@ -0,0 +1,32 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username");
+}
+$username = $options['user'];
+if (!ctype_alnum($username)) {
+	fail('FreshRSS error: invalid username “' . $username . '”');
+}
+
+$usernames = listUsers();
+if (!preg_grep("/^$username$/i", $usernames)) {
+	fail('FreshRSS error: username not found “' . $username . '”');
+}
+
+if (strcasecmp($username, FreshRSS_Context::$system_conf->default_user) === 0) {
+	fail('FreshRSS error: default user must not be deleted: “' . $username . '”');
+}
+
+echo 'FreshRSS deleting user “', $username, "”…\n";
+
+$ok = FreshRSS_user_Controller::deleteUser($username);
+
+invalidateHttpCache(FreshRSS_Context::$system_conf->default_user);
+
+done($ok);

+ 102 - 0
cli/do-install.php

@@ -0,0 +1,102 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+require(LIB_PATH . '/lib_install.php');
+
+$params = array(
+		'environment:',
+		'base_url:',
+		'language:',
+		'title:',
+		'default_user:',
+		'allow_anonymous',
+		'allow_anonymous_refresh',
+		'auth_type:',
+		'api_enabled',
+		'allow_robots',
+	);
+
+$dBparams = array(
+		'db-type:',
+		'db-host:',
+		'db-user:',
+		'db-password:',
+		'db-base:',
+		'db-prefix:',
+	);
+
+$options = getopt('', array_merge($params, $dBparams));
+
+if (empty($options['default_user']) || empty($options['auth_type'])) {
+	fail('Usage: ' . basename(__FILE__) . " --default_user admin --auth_type form" .
+		" ( --environment production --base_url https://rss.example.net/" .
+		" --language en --title FreshRSS --allow_anonymous --api_enabled" .
+		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
+		" --db-base freshrss --db-prefix freshrss )");
+}
+
+fwrite(STDERR, 'FreshRSS install…' . "\n");
+
+$requirements = checkRequirements();
+if ($requirements['all'] !== 'ok') {
+	$message = 'FreshRSS install failed requirements:' . "\n";
+	foreach ($requirements as $requirement => $check) {
+		if ($check !== 'ok' && $requirement !== 'all') {
+			$message .= '• ' . $requirement . "\n";
+		}
+	}
+	fail($message);
+}
+
+if (!ctype_alnum($options['default_user'])) {
+	fail('FreshRSS invalid default username (must be ASCII alphanumeric): ' . $options['default_user']);
+}
+
+if (!in_array($options['auth_type'], array('form', 'http_auth', 'none'))) {
+	fail('FreshRSS invalid authentication method (auth_type must be one of { form, http_auth, none }: ' . $options['auth_type']);
+}
+
+$config = array(
+		'salt' => generateSalt(),
+		'db' => FreshRSS_Context::$system_conf->db,
+	);
+
+foreach ($params as $param) {
+	$param = rtrim($param, ':');
+	if (isset($options[$param])) {
+		$config[$param] = $options[$param] === false ? true : $options[$param];
+	}
+}
+
+if ((!empty($config['base_url'])) && server_is_public($config['base_url'])) {
+	$config['pubsubhubbub_enabled'] = true;
+}
+
+foreach ($dBparams as $dBparam) {
+	$dBparam = rtrim($dBparam, ':');
+	if (!empty($options[$dBparam])) {
+		$param = substr($dBparam, strlen('db-'));
+		$config['db'][$param] = $options[$dBparam];
+	}
+}
+
+if (file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config, true) . ";\n") === false) {
+	fail('FreshRSS could not write configuration file!: ' . join_path(DATA_PATH, 'config.php'));
+}
+
+$config['db']['default_user'] = $config['default_user'];
+if (!checkDb($config['db'])) {
+	@unlink(join_path(DATA_PATH, 'config.php'));
+	fail('FreshRSS database error: ' . (empty($config['db']['bd_error']) ? 'Unknown error' : $config['db']['bd_error']));
+}
+
+echo '• Remember to create the default user: ', $config['default_user'] , "\n",
+	"\t", './cli/create-user.php --user ', $config['default_user'] , " --password 'password' --more-options\n";
+
+accessRights();
+
+if (!deleteInstall()) {
+	fail('FreshRSS access right problem while deleting install file!');
+}
+
+done();

+ 24 - 0
cli/export-opml-for-user.php

@@ -0,0 +1,24 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username > /path/to/file.opml.xml");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS exporting OPML for user “' . $username . "”…\n");
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+$ok = $importController->exportFile(true, false, array(), 0, $username);
+
+invalidateHttpCache($username);
+
+done($ok);

+ 30 - 0
cli/export-zip-for-user.php

@@ -0,0 +1,30 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'max-feed-entries:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username ( --max-feed-entries 100 ) > /path/to/file.zip");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS exporting ZIP for user “' . $username . "”…\n");
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+try {
+	$ok = $importController->exportFile(true, true, true,
+		empty($options['max-feed-entries']) ? 100 : intval($options['max-feed-entries']),
+		$username);
+} catch (FreshRSS_ZipMissing_Exception $zme) {
+	fail('FreshRSS error: Lacking php-zip extension!');
+}
+invalidateHttpCache($username);
+
+done($ok);

+ 35 - 0
cli/import-for-user.php

@@ -0,0 +1,35 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'filename:',
+	));
+
+if (empty($options['user']) || empty($options['filename'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username --filename /path/to/file.ext");
+}
+
+$username = cliInitUser($options['user']);
+
+$filename = $options['filename'];
+if (!is_readable($filename)) {
+	fail('FreshRSS error: file is not readable “' . $filename . '”');
+}
+
+echo 'FreshRSS importing ZIP/OPML/JSON for user “', $username, "”…\n";
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+try {
+	$ok = $importController->importFile($filename, $filename, $username);
+} catch (FreshRSS_ZipMissing_Exception $zme) {
+	fail('FreshRSS error: Lacking php-zip extension!');
+} catch (FreshRSS_Zip_Exception $ze) {
+	fail('FreshRSS error: ZIP archive cannot be imported! Error code: ' . $ze->zipErrorCode());
+}
+invalidateHttpCache($username);
+
+done($ok);

+ 13 - 0
cli/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-GB" lang="en-GB">
+<head>
+<meta charset="UTF-8" />
+<meta http-equiv="Refresh" content="0; url=/" />
+<title>Redirection</title>
+<meta name="robots" content="noindex" />
+</head>
+
+<body>
+<p><a href="/">Redirection</a></p>
+</body>
+</html>

+ 14 - 0
cli/list-users.php

@@ -0,0 +1,14 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$users = listUsers();
+sort($users);
+if (FreshRSS_Context::$system_conf->default_user !== '') {
+	array_unshift($users, FreshRSS_Context::$system_conf->default_user);
+	$users = array_unique($users);
+}
+
+foreach ($users as $user) {
+	echo $user, "\n";
+}

+ 7 - 7
lib/Minz/ModelPdo.php

@@ -36,22 +36,22 @@ class Minz_ModelPdo {
 	 * HOST, BASE, USER et PASS définies dans le fichier de configuration
 	 */
 	public function __construct($currentUser = null) {
-		if (self::$useSharedBd && self::$sharedBd != null && $currentUser === null) {
+		if ($currentUser === null) {
+			$currentUser = Minz_Session::param('currentUser');
+		}
+		if (self::$useSharedBd && self::$sharedBd != null && 
+			($currentUser == null || $currentUser === self::$sharedCurrentUser)) {
 			$this->bd = self::$sharedBd;
 			$this->prefix = self::$sharedPrefix;
 			$this->current_user = self::$sharedCurrentUser;
 			return;
 		}
+		$this->current_user = $currentUser;
+		self::$sharedCurrentUser = $currentUser;
 
 		$conf = Minz_Configuration::get('system');
 		$db = $conf->db;
 
-		if ($currentUser === null) {
-			$currentUser = Minz_Session::param('currentUser', '_');
-		}
-		$this->current_user = $currentUser;
-		self::$sharedCurrentUser = $currentUser;
-
 		$driver_options = isset($conf->db['pdo_options']) && is_array($conf->db['pdo_options']) ? $conf->db['pdo_options'] : array();
 		$dbServer = parse_url('db://' . $db['host']);
 

+ 115 - 0
lib/lib_install.php

@@ -0,0 +1,115 @@
+<?php
+
+define('BCRYPT_COST', 9);
+
+Minz_Configuration::register('default_system', join_path(DATA_PATH, 'config.default.php'));
+Minz_Configuration::register('default_user', join_path(USERS_PATH, '_', 'config.default.php'));
+
+function checkRequirements() {
+	$php = version_compare(PHP_VERSION, '5.3.3') >= 0;
+	$minz = file_exists(join_path(LIB_PATH, 'Minz'));
+	$curl = extension_loaded('curl');
+	$pdo_mysql = extension_loaded('pdo_mysql');
+	$pdo_sqlite = extension_loaded('pdo_sqlite');
+	$pdo_pgsql = extension_loaded('pdo_pgsql');
+	$pdo = $pdo_mysql || $pdo_sqlite || $pdo_pgsql;
+	$pcre = extension_loaded('pcre');
+	$ctype = extension_loaded('ctype');
+	$dom = class_exists('DOMDocument');
+	$xml = function_exists('xml_parser_create');
+	$json = function_exists('json_encode');
+	$data = DATA_PATH && is_writable(DATA_PATH);
+	$cache = CACHE_PATH && is_writable(CACHE_PATH);
+	$users = USERS_PATH && is_writable(USERS_PATH);
+	$favicons = is_writable(join_path(DATA_PATH, 'favicons'));
+	$http_referer = is_referer_from_same_domain();
+
+	return array(
+		'php' => $php ? 'ok' : 'ko',
+		'minz' => $minz ? 'ok' : 'ko',
+		'curl' => $curl ? 'ok' : 'ko',
+		'pdo-mysql' => $pdo_mysql ? 'ok' : 'ko',
+		'pdo-sqlite' => $pdo_sqlite ? 'ok' : 'ko',
+		'pdo-pgsql' => $pdo_pgsql ? 'ok' : 'ko',
+		'pdo' => $pdo ? 'ok' : 'ko',
+		'pcre' => $pcre ? 'ok' : 'ko',
+		'ctype' => $ctype ? 'ok' : 'ko',
+		'dom' => $dom ? 'ok' : 'ko',
+		'xml' => $xml ? 'ok' : 'ko',
+		'json' => $json ? 'ok' : 'ko',
+		'data' => $data ? 'ok' : 'ko',
+		'cache' => $cache ? 'ok' : 'ko',
+		'users' => $users ? 'ok' : 'ko',
+		'favicons' => $favicons ? 'ok' : 'ko',
+		'http_referer' => $http_referer ? 'ok' : 'ko',
+		'all' => $php && $minz && $curl && $pdo && $pcre && $ctype && $dom && $xml &&
+		         $data && $cache && $users && $favicons && $http_referer ?
+		         'ok' : 'ko'
+	);
+}
+
+function generateSalt() {
+	return sha1(uniqid(mt_rand(), true).implode('', stat(__FILE__)));
+}
+
+function checkDb(&$dbOptions) {
+	$dsn = '';
+	try {
+		$driver_options = null;
+		switch ($dbOptions['type']) {
+		case 'mysql':
+			include_once(APP_PATH . '/SQL/install.sql.mysql.php');
+			$driver_options = array(
+				PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4'
+			);
+			try {	// on ouvre une connexion juste pour créer la base si elle n'existe pas
+				$dsn = 'mysql:host=' . $dbOptions['host'] . ';';
+				$c = new PDO($dsn, $dbOptions['user'], $dbOptions['password'], $driver_options);
+				$sql = sprintf(SQL_CREATE_DB, $dbOptions['base']);
+				$res = $c->query($sql);
+			} catch (PDOException $e) {
+				syslog(LOG_DEBUG, 'FreshRSS MySQL warning: ' . $e->getMessage());
+			}
+			// on écrase la précédente connexion en sélectionnant la nouvelle BDD
+			$dsn = 'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['base'];
+			break;
+		case 'sqlite':
+			include_once(APP_PATH . '/SQL/install.sql.sqlite.php');
+			$dsn = 'sqlite:' . join_path(USERS_PATH, $dbOptions['default_user'], 'db.sqlite');
+			$driver_options = array(
+				PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+			);
+			break;
+		case 'pgsql':
+			include_once(APP_PATH . '/SQL/install.sql.pgsql.php');
+			$driver_options = array(
+				PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+			);
+			try {	// on ouvre une connexion juste pour créer la base si elle n'existe pas
+				$dsn = 'pgsql:host=' . $dbOptions['host'] . ';dbname=postgres';
+				$c = new PDO($dsn, $dbOptions['user'], $dbOptions['password'], $driver_options);
+				$sql = sprintf(SQL_CREATE_DB, $dbOptions['base']);
+				$res = $c->query($sql);
+			} catch (PDOException $e) {
+				syslog(LOG_DEBUG, 'FreshRSS PostgreSQL warning: ' . $e->getMessage());
+			}
+			// on écrase la précédente connexion en sélectionnant la nouvelle BDD
+			$dsn = 'pgsql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['base'];
+			break;
+		default:
+			return false;
+		}
+	} catch (PDOException $e) {
+		$dsn = '';
+		$dbOptions['error'] = $e->getMessage();
+	}
+	$dbOptions['dsn'] = $dsn;
+	$dbOptions['options'] = $driver_options;
+	return $dsn != '';
+}
+
+function deleteInstall() {
+	$path = join_path(DATA_PATH, 'do-install.txt');
+	@unlink($path);
+	return !file_exists($path);
+}

+ 6 - 3
lib/lib_rss.php

@@ -282,9 +282,12 @@ function uSecString() {
 	return str_pad($t['usec'], 6, '0');
 }
 
-function invalidateHttpCache() {
-	Minz_Session::_param('touch', uTimeString());
-	return touch(join_path(DATA_PATH, 'users', Minz_Session::param('currentUser', '_'), 'log.txt'));
+function invalidateHttpCache($username = '') {
+	if (($username == '') || (!ctype_alnum($username))) {
+		Minz_Session::_param('touch', uTimeString());
+		$username = Minz_Session::param('currentUser', '_');
+	}
+	return touch(join_path(DATA_PATH, 'users', $username, 'log.txt'));
 }
 
 function listUsers() {

+ 0 - 3
p/themes/Pafat/pafat.css

@@ -48,9 +48,6 @@ input, select, textarea {
 	vertical-align: middle;
 }
 
-select{
-	height:29px;
-}
 option {
 	padding: 0 .5em;
 }