PHPIndex

This page lists files in the current directory. You can view content, get download/execute commands for Wget, Curl, or PowerShell, or filter the list using wildcards (e.g., `*.sh`).

apiController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/apiController.php'
View Content
<?php
declare(strict_types=1);

/**
 * This controller manage API-related features.
 */
class FreshRSS_api_Controller extends FreshRSS_ActionController {

	/**
	 * Update the user API password.
	 * Return an error message, or `false` if no error.
	 * @return false|string
	 */
	public static function updatePassword(string $apiPasswordPlain) {
		$username = Minz_User::name();
		if ($username == null) {
			return _t('feedback.api.password.failed');
		}

		$apiPasswordHash = FreshRSS_password_Util::hash($apiPasswordPlain);
		FreshRSS_Context::userConf()->apiPasswordHash = $apiPasswordHash;

		$feverKey = FreshRSS_fever_Util::updateKey($username, $apiPasswordPlain);
		if ($feverKey == false) {
			return _t('feedback.api.password.failed');
		}

		FreshRSS_Context::userConf()->feverKey = $feverKey;
		if (FreshRSS_Context::userConf()->save()) {
			return false;
		} else {
			return _t('feedback.api.password.failed');
		}
	}

	/**
	 * This action updates the user API password.
	 *
	 * Parameter is:
	 * - apiPasswordPlain: the new user password
	 */
	public function updatePasswordAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$return_url = ['c' => 'user', 'a' => 'profile'];

		if (!Minz_Request::isPost()) {
			Minz_Request::forward($return_url, true);
		}

		$apiPasswordPlain = Minz_Request::paramString('apiPasswordPlain', true);
		if ($apiPasswordPlain == '') {
			Minz_Request::forward($return_url, true);
		}

		$error = self::updatePassword($apiPasswordPlain);
		if (is_string($error)) {
			Minz_Request::bad($error, $return_url);
		} else {
			Minz_Request::good(_t('feedback.api.password.updated'), $return_url);
		}
	}
}
authController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/authController.php'
View Content
<?php
declare(strict_types=1);

/**
 * This controller handles action about authentication.
 */
class FreshRSS_auth_Controller extends FreshRSS_ActionController {
	/**
	 * This action handles authentication management page.
	 *
	 * Parameters are:
	 *   - token (default: current token)
	 *   - anon_access (default: false)
	 *   - anon_refresh (default: false)
	 *   - auth_type (default: none)
	 *   - unsafe_autologin (default: false)
	 *   - api_enabled (default: false)
	 */
	public function indexAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		FreshRSS_View::prependTitle(_t('admin.auth.title') . ' · ');

		if (Minz_Request::isPost()) {
			$ok = true;

			$anon = Minz_Request::paramBoolean('anon_access');
			$anon_refresh = Minz_Request::paramBoolean('anon_refresh');
			$auth_type = Minz_Request::paramString('auth_type') ?: 'form';
			$unsafe_autologin = Minz_Request::paramBoolean('unsafe_autologin');
			$api_enabled = Minz_Request::paramBoolean('api_enabled');
			if ($anon !== FreshRSS_Context::systemConf()->allow_anonymous ||
				$auth_type !== FreshRSS_Context::systemConf()->auth_type ||
				$anon_refresh !== FreshRSS_Context::systemConf()->allow_anonymous_refresh ||
				$unsafe_autologin !== FreshRSS_Context::systemConf()->unsafe_autologin_enabled ||
				$api_enabled !== FreshRSS_Context::systemConf()->api_enabled) {
				if (in_array($auth_type, ['form', 'http_auth', 'none'], true)) {
					FreshRSS_Context::systemConf()->auth_type = $auth_type;
				} else {
					FreshRSS_Context::systemConf()->auth_type = 'form';
				}
				FreshRSS_Context::systemConf()->allow_anonymous = $anon;
				FreshRSS_Context::systemConf()->allow_anonymous_refresh = $anon_refresh;
				FreshRSS_Context::systemConf()->unsafe_autologin_enabled = $unsafe_autologin;
				FreshRSS_Context::systemConf()->api_enabled = $api_enabled;

				$ok &= FreshRSS_Context::systemConf()->save();
			}

			invalidateHttpCache();

			if ($ok) {
				Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'auth', 'a' => 'index' ]);
			} else {
				Minz_Request::bad(_t('feedback.conf.error'), [ 'c' => 'auth', 'a' => 'index' ]);
			}
		}
	}

	/**
	 * This action handles the login page.
	 *
	 * It forwards to the correct login page (form) or main page if
	 * the user is already connected.
	 */
	public function loginAction(): void {
		if (FreshRSS_Auth::hasAccess() && Minz_Request::paramString('u') === '') {
			Minz_Request::forward(['c' => 'index', 'a' => 'index'], true);
		}

		$auth_type = FreshRSS_Context::systemConf()->auth_type;
		FreshRSS_Context::initUser(Minz_User::INTERNAL_USER, false);
		switch ($auth_type) {
			case 'form':
				Minz_Request::forward(['c' => 'auth', 'a' => 'formLogin']);
				break;
			case 'http_auth':
				Minz_Error::error(403, [
					'error' => [
						_t('feedback.access.denied'),
						' [HTTP Remote-User=' . htmlspecialchars(httpAuthUser(false), ENT_NOQUOTES, 'UTF-8') .
						' ; Remote IP address=' . connectionRemoteAddress() . ']'
					]
				], false);
				break;
			case 'none':
				// It should not happen!
				Minz_Error::error(404);
				break;
			default:
				// TODO load plugin instead
				Minz_Error::error(404);
		}
	}

	/**
	 * This action handles form login page.
	 *
	 * If this action is reached through a POST request, username and password
	 * are compared to login the current user.
	 *
	 * Parameters are:
	 *   - nonce (default: false)
	 *   - username (default: '')
	 *   - challenge (default: '')
	 *   - keep_logged_in (default: false)
	 *
	 * @todo move unsafe autologin in an extension.
	 * @throws Exception
	 */
	public function formLoginAction(): void {
		invalidateHttpCache();

		FreshRSS_View::prependTitle(_t('gen.auth.login') . ' · ');
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')));

		$limits = FreshRSS_Context::systemConf()->limits;
		$this->view->cookie_days = (int)round($limits['cookie_duration'] / 86400, 1);

		$isPOST = Minz_Request::isPost() && !Minz_Session::paramBoolean('POST_to_GET');
		Minz_Session::_param('POST_to_GET');

		if ($isPOST) {
			$nonce = Minz_Session::paramString('nonce');
			$username = Minz_Request::paramString('username');
			$challenge = Minz_Request::paramString('challenge');

			if ($nonce === '') {
				Minz_Log::warning("Invalid session during login for user={$username}, nonce={$nonce}");
				header('HTTP/1.1 403 Forbidden');
				Minz_Session::_param('POST_to_GET', true);	//Prevent infinite internal redirect
				Minz_Request::setBadNotification(_t('install.session.nok'));
				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
				return;
			}

			usleep(random_int(100, 10000));	//Primitive mitigation of timing attacks, in μs

			FreshRSS_Context::initUser($username);
			if (!FreshRSS_Context::hasUserConf()) {
				// Initialise the default user to be able to display the error page
				FreshRSS_Context::initUser(FreshRSS_Context::systemConf()->default_user);
				Minz_Error::error(403, _t('feedback.auth.login.invalid'), false);
				return;
			}

			if (!FreshRSS_Context::userConf()->enabled || FreshRSS_Context::userConf()->passwordHash == '') {
				usleep(random_int(100, 5000));	//Primitive mitigation of timing attacks, in μs
				Minz_Error::error(403, _t('feedback.auth.login.invalid'), false);
				return;
			}

			$ok = FreshRSS_FormAuth::checkCredentials(
				$username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge
			);
			if ($ok) {
				// Set session parameter to give access to the user.
				Minz_Session::_params([
					Minz_User::CURRENT_USER => $username,
					'passwordHash' => FreshRSS_Context::userConf()->passwordHash,
					'csrf' => false,
				]);
				FreshRSS_Auth::giveAccess();

				// Set cookie parameter if needed.
				if (Minz_Request::paramBoolean('keep_logged_in')) {
					FreshRSS_FormAuth::makeCookie($username, FreshRSS_Context::userConf()->passwordHash);
				} else {
					FreshRSS_FormAuth::deleteCookie();
				}

				Minz_Translate::init(FreshRSS_Context::userConf()->language);

				// All is good, go back to the original request or the index.
				$url = Minz_Url::unserialize(Minz_Request::paramString('original_request'));
				if (empty($url)) {
					$url = [ 'c' => 'index', 'a' => 'index' ];
				}
				Minz_Request::good(_t('feedback.auth.login.success'), $url);
			} else {
				Minz_Log::warning("Password mismatch for user={$username}, nonce={$nonce}, c={$challenge}");
				header('HTTP/1.1 403 Forbidden');
				Minz_Session::_param('POST_to_GET', true);	//Prevent infinite internal redirect
				Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
			}
		} elseif (FreshRSS_Context::systemConf()->unsafe_autologin_enabled) {
			$username = Minz_Request::paramString('u');
			$password = Minz_Request::paramString('p');
			Minz_Request::_param('p');

			if ($username === '') {
				return;
			}

			FreshRSS_FormAuth::deleteCookie();

			FreshRSS_Context::initUser($username);
			if (!FreshRSS_Context::hasUserConf()) {
				return;
			}

			$s = FreshRSS_Context::userConf()->passwordHash;
			$ok = password_verify($password, $s);
			unset($password);
			if ($ok) {
				Minz_Session::_params([
					Minz_User::CURRENT_USER => $username,
					'passwordHash' => $s,
					'csrf' => false,
				]);
				FreshRSS_Auth::giveAccess();

				Minz_Translate::init(FreshRSS_Context::userConf()->language);

				Minz_Request::good(_t('feedback.auth.login.success'), ['c' => 'index', 'a' => 'index']);
			} else {
				Minz_Log::warning('Unsafe password mismatch for user ' . $username);
				Minz_Request::bad(
					_t('feedback.auth.login.invalid'),
					['c' => 'auth', 'a' => 'login']
				);
			}
		}
	}

	/**
	 * This action removes all accesses of the current user.
	 */
	public function logoutAction(): void {
		invalidateHttpCache();
		FreshRSS_Auth::removeAccess();
		Minz_Request::good(_t('feedback.auth.logout.success'), [ 'c' => 'index', 'a' => 'index' ]);
	}

	/**
	 * This action gives possibility to a user to create an account.
	 *
	 * The user is redirected to the home when logged in.
	 *
	 * A 403 is sent if max number of registrations is reached.
	 */
	public function registerAction(): void {
		if (FreshRSS_Auth::hasAccess()) {
			Minz_Request::forward(['c' => 'index', 'a' => 'index'], true);
		}

		if (max_registrations_reached()) {
			Minz_Error::error(403);
		}

		$this->view->show_tos_checkbox = file_exists(TOS_FILENAME);
		$this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation;
		$this->view->preferred_language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
		FreshRSS_View::prependTitle(_t('gen.auth.registration.title') . ' · ');
	}

	public static function getLogoutUrl(): string {
		if (($_SERVER['AUTH_TYPE'] ?? '') === 'openid-connect') {
			$url_string = urlencode(Minz_Request::guessBaseUrl());
			return './oidc/?logout=' . $url_string . '/';
			# The trailing slash is necessary so that we don’t redirect to http://.
			# https://bz.apache.org/bugzilla/show_bug.cgi?id=61355#c13
		} else {
			return _url('auth', 'logout') ?: '';
		}
	}
}
categoryController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/categoryController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle actions relative to categories.
 * User needs to be connected.
 */
class FreshRSS_category_Controller extends FreshRSS_ActionController {
	/**
	 * This action is called before every other action in that class. It is
	 * the common boiler plate for every action. It is triggered by the
	 * underlying framework.
	 *
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$catDAO = FreshRSS_Factory::createCategoryDao();
		$catDAO->checkDefault();
	}

	/**
	 * This action creates a new category.
	 *
	 * Request parameter is:
	 *   - new-category
	 */
	public function createAction(): void {
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$tagDAO = FreshRSS_Factory::createTagDao();

		$url_redirect = ['c' => 'subscription', 'a' => 'add'];

		$limits = FreshRSS_Context::systemConf()->limits;
		$this->view->categories = $catDAO->listCategories(false) ?: [];

		if (count($this->view->categories) >= $limits['max_categories']) {
			Minz_Request::bad(_t('feedback.sub.category.over_max', $limits['max_categories']), $url_redirect);
		}

		if (Minz_Request::isPost()) {
			invalidateHttpCache();

			$cat_name = Minz_Request::paramString('new-category');
			if ($cat_name === '') {
				Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
			}

			$cat = new FreshRSS_Category($cat_name);

			if ($catDAO->searchByName($cat->name()) != null) {
				Minz_Request::bad(_t('feedback.sub.category.name_exists'), $url_redirect);
			}

			if ($tagDAO->searchByName($cat->name()) != null) {
				Minz_Request::bad(_t('feedback.tag.name_exists', $cat->name()), $url_redirect);
			}

			$opml_url = checkUrl(Minz_Request::paramString('opml_url'));
			if ($opml_url != '') {
				$cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
				$cat->_attribute('opml_url', $opml_url);
			} else {
				$cat->_kind(FreshRSS_Category::KIND_NORMAL);
				$cat->_attribute('opml_url', null);
			}

			if ($catDAO->addCategoryObject($cat)) {
				$url_redirect['a'] = 'index';
				Minz_Request::good(_t('feedback.sub.category.created', $cat->name()), $url_redirect);
			} else {
				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
			}
		}

		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * This action updates the given category.
	 */
	public function updateAction(): void {
		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		}

		$categoryDAO = FreshRSS_Factory::createCategoryDao();

		$id = Minz_Request::paramInt('id');
		$category = $categoryDAO->searchById($id);
		if ($id === 0 || null === $category) {
			Minz_Error::error(404);
			return;
		}
		$this->view->category = $category;

		FreshRSS_View::prependTitle($category->name() . ' · ' . _t('sub.title') . ' · ');

		if (Minz_Request::isPost()) {
			$category->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));

			if (Minz_Request::paramBoolean('use_default_purge_options')) {
				$category->_attribute('archiving', null);
			} else {
				if (!Minz_Request::paramBoolean('enable_keep_max')) {
					$keepMax = false;
				} elseif (($keepMax = Minz_Request::paramInt('keep_max')) !== 0) {
					$keepMax = FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
				}
				if (Minz_Request::paramBoolean('enable_keep_period')) {
					$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
					if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
						$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
					}
				} else {
					$keepPeriod = false;
				}
				$category->_attribute('archiving', [
					'keep_period' => $keepPeriod,
					'keep_max' => $keepMax,
					'keep_min' => Minz_Request::paramInt('keep_min'),
					'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
					'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
					'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
				]);
			}

			$position = Minz_Request::paramInt('position') ?: null;
			$category->_attribute('position', $position);

			$opml_url = checkUrl(Minz_Request::paramString('opml_url'));
			if ($opml_url != '') {
				$category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
				$category->_attribute('opml_url', $opml_url);
			} else {
				$category->_kind(FreshRSS_Category::KIND_NORMAL);
				$category->_attribute('opml_url', null);
			}

			$values = [
				'kind' => $category->kind(),
				'name' => Minz_Request::paramString('name'),
				'attributes' => $category->attributes(),
			];

			invalidateHttpCache();

			$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id, 'type' => 'category']];
			if (false !== $categoryDAO->updateCategory($id, $values)) {
				Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
			} else {
				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
			}
		}
	}

	/**
	 * This action deletes a category.
	 * Feeds in the given category are moved in the default category.
	 * Related user queries are deleted too.
	 *
	 * Request parameter is:
	 *   - id (of a category)
	 */
	public function deleteAction(): void {
		$feedDAO = FreshRSS_Factory::createFeedDao();
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$url_redirect = ['c' => 'subscription', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			invalidateHttpCache();

			$id = Minz_Request::paramInt('id');
			if ($id === 0) {
				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
			}

			if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
				Minz_Request::bad(_t('feedback.sub.category.not_delete_default'), $url_redirect);
			}

			if ($feedDAO->changeCategory($id, FreshRSS_CategoryDAO::DEFAULTCATEGORYID) === false) {
				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
			}

			if ($catDAO->deleteCategory($id) === false) {
				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
			}

			// Remove related queries.
			/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries */
			$queries = remove_query_by_get('c_' . $id, FreshRSS_Context::userConf()->queries);
			FreshRSS_Context::userConf()->queries = $queries;
			FreshRSS_Context::userConf()->save();

			Minz_Request::good(_t('feedback.sub.category.deleted'), $url_redirect);
		}

		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * This action deletes all the feeds relative to a given category.
	 * Feed-related queries are deleted.
	 *
	 * Request parameter is:
	 *   - id (of a category)
	 *   - muted (truthy to remove only muted feeds, or falsy otherwise)
	 */
	public function emptyAction(): void {
		$feedDAO = FreshRSS_Factory::createFeedDao();
		$url_redirect = ['c' => 'subscription', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			invalidateHttpCache();

			$id = Minz_Request::paramInt('id');
			if ($id === 0) {
				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
			}

			$muted = Minz_Request::paramTernary('muted');

			// List feeds to remove then related user queries.
			$feeds = $feedDAO->listByCategory($id, $muted);

			if ($feedDAO->deleteFeedByCategory($id, $muted)) {
				// TODO: Delete old favicons

				// Remove related queries
				foreach ($feeds as $feed) {
					/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> */
					$queries = remove_query_by_get('f_' . $feed->id(), FreshRSS_Context::userConf()->queries);
					FreshRSS_Context::userConf()->queries = $queries;
				}
				FreshRSS_Context::userConf()->save();

				Minz_Request::good(_t('feedback.sub.category.emptied'), $url_redirect);
			} else {
				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
			}
		}

		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * Request parameter is:
	 * - id (of a category)
	 */
	public function refreshOpmlAction(): void {
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$url_redirect = ['c' => 'subscription', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			invalidateHttpCache();

			$id = Minz_Request::paramInt('id');
			if ($id === 0) {
				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
				return;
			}

			$category = $catDAO->searchById($id);
			if ($category === null) {
				Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect);
				return;
			}

			invalidateHttpCache();

			$ok = $category->refreshDynamicOpml();

			if (Minz_Request::paramBoolean('ajax')) {
				Minz_Request::setGoodNotification(_t('feedback.sub.category.updated'));
				$this->view->_layout(null);
			} else {
				if ($ok) {
					Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
				} else {
					Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
				}
				Minz_Request::forward($url_redirect, true);
			}
		}
	}

	/** @return array<string,int> */
	public static function refreshDynamicOpmls(): array {
		$successes = 0;
		$errors = 0;
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::userConf()->dynamic_opml_ttl_default ?? 86400);
		foreach ($categories as $category) {
			if ($category->refreshDynamicOpml()) {
				$successes++;
			} else {
				$errors++;
			}
		}
		return [
			'successes' => $successes,
			'errors' => $errors,
		];
	}
}
configureController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/configureController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle every configuration options.
 */
class FreshRSS_configure_Controller extends FreshRSS_ActionController {
	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
	}

	/**
	 * This action handles the display configuration page.
	 *
	 * It displays the display configuration page.
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The options available on the page are:
	 *   - language (default: en)
	 *   - theme (default: Origin)
	 *   - darkMode (default: auto)
	 *   - content width (default: thin)
	 *   - display of read action in header
	 *   - display of favorite action in header
	 *   - display of date in header
	 *   - display of open action in header
	 *   - display of read action in footer
	 *   - display of favorite action in footer
	 *   - display of sharing action in footer
	 *   - display of article tags in footer
	 *   - display of my Labels in footer
	 *   - display of date in footer
	 *   - display of open action in footer
	 *   - html5 notification timeout (default: 0)
	 * Default values are false unless specified.
	 */
	public function displayAction(): void {
		if (Minz_Request::isPost()) {
			FreshRSS_Context::userConf()->language = Minz_Request::paramString('language') ?: 'en';
			FreshRSS_Context::userConf()->timezone = Minz_Request::paramString('timezone');
			FreshRSS_Context::userConf()->theme = Minz_Request::paramString('theme') ?: FreshRSS_Themes::$defaultTheme;
			FreshRSS_Context::userConf()->darkMode = Minz_Request::paramString('darkMode') ?: 'auto';
			FreshRSS_Context::userConf()->content_width = Minz_Request::paramString('content_width') ?: 'thin';
			FreshRSS_Context::userConf()->topline_read = Minz_Request::paramBoolean('topline_read');
			FreshRSS_Context::userConf()->topline_favorite = Minz_Request::paramBoolean('topline_favorite');
			FreshRSS_Context::userConf()->topline_sharing = Minz_Request::paramBoolean('topline_sharing');
			FreshRSS_Context::userConf()->topline_date = Minz_Request::paramBoolean('topline_date');
			FreshRSS_Context::userConf()->topline_link = Minz_Request::paramBoolean('topline_link');
			FreshRSS_Context::userConf()->topline_website = Minz_Request::paramString('topline_website');
			FreshRSS_Context::userConf()->topline_thumbnail = Minz_Request::paramString('topline_thumbnail');
			FreshRSS_Context::userConf()->topline_summary = Minz_Request::paramBoolean('topline_summary');
			FreshRSS_Context::userConf()->topline_display_authors = Minz_Request::paramBoolean('topline_display_authors');
			FreshRSS_Context::userConf()->bottomline_read = Minz_Request::paramBoolean('bottomline_read');
			FreshRSS_Context::userConf()->bottomline_favorite = Minz_Request::paramBoolean('bottomline_favorite');
			FreshRSS_Context::userConf()->bottomline_sharing = Minz_Request::paramBoolean('bottomline_sharing');
			FreshRSS_Context::userConf()->bottomline_tags = Minz_Request::paramBoolean('bottomline_tags');
			FreshRSS_Context::userConf()->bottomline_myLabels = Minz_Request::paramBoolean('bottomline_myLabels');
			FreshRSS_Context::userConf()->bottomline_date = Minz_Request::paramBoolean('bottomline_date');
			FreshRSS_Context::userConf()->bottomline_link = Minz_Request::paramBoolean('bottomline_link');
			FreshRSS_Context::userConf()->show_nav_buttons = Minz_Request::paramBoolean('show_nav_buttons');
			FreshRSS_Context::userConf()->html5_notif_timeout = Minz_Request::paramInt('html5_notif_timeout');
			FreshRSS_Context::userConf()->save();

			Minz_Session::_param('language', FreshRSS_Context::userConf()->language);
			Minz_Translate::reset(FreshRSS_Context::userConf()->language);
			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'display' ]);
		}

		$this->view->themes = FreshRSS_Themes::get();

		FreshRSS_View::prependTitle(_t('conf.display.title') . ' · ');
	}

	/**
	 * This action handles the reading configuration page.
	 *
	 * It displays the reading configuration page.
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The options available on the page are:
	 *   - number of posts per page (default: 10)
	 *   - view mode (default: normal)
	 *   - default article view (default: all)
	 *   - load automatically articles
	 *   - display expanded articles
	 *   - display expanded categories
	 *   - hide categories and feeds without unread articles
	 *   - jump on next category or feed when marked as read
	 *   - image lazy loading
	 *   - stick open articles to the top
	 *   - display a confirmation when reading all articles
	 *   - auto remove article after reading
	 *   - article order (default: DESC)
	 *   - mark articles as read when:
	 *       - displayed
	 *       - opened on site
	 *       - scrolled
	 *       - received
	 *       - focus
	 * Default values are false unless specified.
	 */
	public function readingAction(): void {
		if (Minz_Request::isPost()) {
			FreshRSS_Context::userConf()->posts_per_page = Minz_Request::paramInt('posts_per_page') ?: 10;
			FreshRSS_Context::userConf()->view_mode = Minz_Request::paramStringNull('view_mode', true) ?? 'normal';
			FreshRSS_Context::userConf()->default_view = Minz_Request::paramStringNull('default_view') ?? 'adaptive';
			FreshRSS_Context::userConf()->show_fav_unread = Minz_Request::paramBoolean('show_fav_unread');
			FreshRSS_Context::userConf()->auto_load_more = Minz_Request::paramBoolean('auto_load_more');
			FreshRSS_Context::userConf()->display_posts = Minz_Request::paramBoolean('display_posts');
			FreshRSS_Context::userConf()->display_categories = Minz_Request::paramStringNull('display_categories') ?? 'active';
			FreshRSS_Context::userConf()->show_tags = Minz_Request::paramStringNull('show_tags') ?? '0';
			FreshRSS_Context::userConf()->show_tags_max = Minz_Request::paramInt('show_tags_max');
			FreshRSS_Context::userConf()->show_author_date = Minz_Request::paramStringNull('show_author_date') ?? '0';
			FreshRSS_Context::userConf()->show_feed_name = Minz_Request::paramStringNull('show_feed_name') ?? 't';
			FreshRSS_Context::userConf()->show_article_icons = Minz_Request::paramStringNull('show_article_icons') ?? 't';
			FreshRSS_Context::userConf()->hide_read_feeds = Minz_Request::paramBoolean('hide_read_feeds');
			FreshRSS_Context::userConf()->onread_jump_next = Minz_Request::paramBoolean('onread_jump_next');
			FreshRSS_Context::userConf()->lazyload = Minz_Request::paramBoolean('lazyload');
			FreshRSS_Context::userConf()->sides_close_article = Minz_Request::paramBoolean('sides_close_article');
			FreshRSS_Context::userConf()->sticky_post = Minz_Request::paramBoolean('sticky_post');
			FreshRSS_Context::userConf()->reading_confirm = Minz_Request::paramBoolean('reading_confirm');
			FreshRSS_Context::userConf()->auto_remove_article = Minz_Request::paramBoolean('auto_remove_article');
			FreshRSS_Context::userConf()->mark_updated_article_unread = Minz_Request::paramBoolean('mark_updated_article_unread');
			if (in_array(Minz_Request::paramString('sort_order'), ['ASC', 'DESC'], true)) {
				FreshRSS_Context::userConf()->sort_order = Minz_Request::paramString('sort_order');
			} else {
				FreshRSS_Context::userConf()->sort_order = 'DESC';
			}
			FreshRSS_Context::userConf()->mark_when = [
				'article' => Minz_Request::paramBoolean('mark_open_article'),
				'gone' => Minz_Request::paramBoolean('read_upon_gone'),
				'max_n_unread' => Minz_Request::paramBoolean('enable_keep_max_n_unread') ? Minz_Request::paramInt('keep_max_n_unread') : false,
				'reception' => Minz_Request::paramBoolean('mark_upon_reception'),
				'same_title_in_feed' => Minz_Request::paramBoolean('enable_read_when_same_title_in_feed') ?
					Minz_Request::paramInt('read_when_same_title_in_feed') : false,
				'scroll' => Minz_Request::paramBoolean('mark_scroll'),
				'site' => Minz_Request::paramBoolean('mark_open_site'),
				'focus' => Minz_Request::paramBoolean('mark_focus'),
			];
			FreshRSS_Context::userConf()->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));
			FreshRSS_Context::userConf()->_filtersAction('star', Minz_Request::paramTextToArray('filteractions_star'));
			FreshRSS_Context::userConf()->save();
			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'reading' ]);
		}

		FreshRSS_View::prependTitle(_t('conf.reading.title') . ' · ');
	}

	/**
	 * This action handles the integration configuration page.
	 *
	 * It displays the integration configuration page.
	 * If this action is reached through a POST request, it stores all
	 * configuration values then sends a notification to the user.
	 *
	 * Before v1.16, we used sharing instead of integration. This has
	 * some unwanted behavior when the end-user was using an ad-blocker.
	 */
	public function integrationAction(): void {
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/integration.js?' . @filemtime(PUBLIC_PATH . '/scripts/integration.js')));
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));

		if (Minz_Request::isPost()) {
			$params = $_POST;
			FreshRSS_Context::userConf()->sharing = $params['share'];
			FreshRSS_Context::userConf()->save();
			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'integration' ]);
		}

		FreshRSS_View::prependTitle(_t('conf.sharing.title') . ' · ');
	}

	/**
	 * This action handles the shortcut configuration page.
	 *
	 * It displays the shortcut configuration page.
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The authorized values for shortcuts are letters (a to z), numbers (0
	 * to 9), function keys (f1 to f12), backspace, delete, down, end, enter,
	 * escape, home, insert, left, page down, page up, return, right, space,
	 * tab and up.
	 */
	public function shortcutAction(): void {
		$this->view->list_keys = SHORTCUT_KEYS;

		if (Minz_Request::isPost()) {
			$shortcuts = Minz_Request::paramArray('shortcuts');
			if (Minz_Request::paramBoolean('load_default_shortcuts')) {
				$default = Minz_Configuration::load(FRESHRSS_PATH . '/config-user.default.php');
				$shortcuts = $default['shortcuts'];
			}
			/** @var array<string,string> $shortcuts */
			FreshRSS_Context::userConf()->shortcuts = array_map('trim', $shortcuts);
			FreshRSS_Context::userConf()->save();
			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.shortcuts_updated'), ['c' => 'configure', 'a' => 'shortcut']);
		}

		FreshRSS_View::prependTitle(_t('conf.shortcut.title') . ' · ');
	}

	/**
	 * This action handles the archive configuration page.
	 *
	 * It displays the archive configuration page.
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The options available on that page are:
	 *   - duration to retain old article (default: 3)
	 *   - number of article to retain per feed (default: 0)
	 *   - refresh frequency (default: 0)
	 */
	public function archivingAction(): void {
		if (Minz_Request::isPost()) {
			if (Minz_Request::paramBoolean('enable_keep_max')) {
				$keepMax = Minz_Request::paramInt('keep_max') ?: FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
			} else {
				$keepMax = false;
			}
			if (Minz_Request::paramBoolean('enable_keep_period')) {
				$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
				if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
					$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
				}
			} else {
				$keepPeriod = false;
			}

			FreshRSS_Context::userConf()->ttl_default = Minz_Request::paramInt('ttl_default') ?: FreshRSS_Feed::TTL_DEFAULT;
			FreshRSS_Context::userConf()->archiving = [
				'keep_period' => $keepPeriod,
				'keep_max' => $keepMax,
				'keep_min' => Minz_Request::paramInt('keep_min_default'),
				'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
				'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
				'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
			];
			FreshRSS_Context::userConf()->keep_history_default = null;	//Legacy < FreshRSS 1.15
			FreshRSS_Context::userConf()->old_entries = null;	//Legacy < FreshRSS 1.15
			FreshRSS_Context::userConf()->save();
			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'archiving' ]);
		}

		$volatile = [
				'enable_keep_period' => false,
				'keep_period_count' => '3',
				'keep_period_unit' => 'P1M',
			];
		if (!empty(FreshRSS_Context::userConf()->archiving['keep_period'])) {
			$keepPeriod = FreshRSS_Context::userConf()->archiving['keep_period'];
			if (preg_match('/^PT?(?P<count>\d+)[YMWDH]$/', $keepPeriod, $matches)) {
				$volatile = [
					'enable_keep_period' => true,
					'keep_period_count' => $matches['count'],
					'keep_period_unit' => str_replace($matches['count'], '1', $keepPeriod),
				];
			}
		}
		FreshRSS_Context::userConf()->volatile = $volatile;

		$entryDAO = FreshRSS_Factory::createEntryDao();
		$this->view->nb_total = $entryDAO->count();

		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
		$this->view->size_user = $databaseDAO->size();

		if (FreshRSS_Auth::hasAccess('admin')) {
			$this->view->size_total = $databaseDAO->size(true);
		}

		FreshRSS_View::prependTitle(_t('conf.archiving.title') . ' · ');
	}

	/**
	 * This action handles the user queries configuration page.
	 *
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user then
	 * redirect to the same page.
	 * If this action is not reached through a POST request, it displays the
	 * configuration page and verifies that every user query is runable by
	 * checking if categories and feeds are still in use.
	 */
	public function queriesAction(): void {
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));

		if (Minz_Request::isPost()) {
			/** @var array<int,array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}> $params */
			$params = Minz_Request::paramArray('queries');

			$queries = [];
			foreach ($params as $key => $query) {
				$key = (int)$key;
				if (empty($query['name'])) {
					$query['name'] = _t('conf.query.number', $key + 1);
				}
				if (!empty($query['search'])) {
					$query['search'] = urldecode($query['search']);
				}
				$queries[$key] = (new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
			}
			FreshRSS_Context::userConf()->queries = $queries;
			FreshRSS_Context::userConf()->save();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'queries' ]);
		} else {
			$this->view->queries = [];
			foreach (FreshRSS_Context::userConf()->queries as $key => $query) {
				$this->view->queries[intval($key)] = new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
			}
		}

		$this->view->categories = FreshRSS_Context::categories();
		$this->view->feeds = FreshRSS_Context::feeds();
		$this->view->tags = FreshRSS_Context::labels();

		if (Minz_Request::paramTernary('id') !== null) {
			$id = Minz_Request::paramInt('id');
			$this->view->query = $this->view->queries[$id];
			$this->view->queryId = $id;
			$this->view->displaySlider = true;
		} else {
			$this->view->displaySlider = false;
		}

		FreshRSS_View::prependTitle(_t('conf.query.title') . ' · ');
	}

	/**
	 * Handles query configuration.
	 * It displays the query configuration page and handles modifications
	 * applied to the selected query.
	 */
	public function queryAction(): void {
		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		}

		$id = Minz_Request::paramInt('id');
		if (Minz_Request::paramTernary('id') === null || empty(FreshRSS_Context::userConf()->queries[$id])) {
			Minz_Error::error(404);
			return;
		}

		$query = new FreshRSS_UserQuery(FreshRSS_Context::userConf()->queries[$id], FreshRSS_Context::categories(), FreshRSS_Context::labels());
		$this->view->query = $query;
		$this->view->queryId = $id;
		$this->view->categories = FreshRSS_Context::categories();
		$this->view->feeds = FreshRSS_Context::feeds();
		$this->view->tags = FreshRSS_Context::labels();

		if (Minz_Request::isPost()) {
			$params = array_filter(Minz_Request::paramArray('query'));
			$queryParams = [];
			$name = Minz_Request::paramString('name') ?: _t('conf.query.number', $id + 1);
			if ('' === $name) {
				$name = _t('conf.query.number', $id + 1);
			}
			if (!empty($params['get']) && is_string($params['get'])) {
				$queryParams['get'] = htmlspecialchars_decode($params['get'], ENT_QUOTES);
			}
			if (!empty($params['order']) && is_string($params['order'])) {
				$queryParams['order'] = htmlspecialchars_decode($params['order'], ENT_QUOTES);
			}
			if (!empty($params['search']) && is_string($params['search'])) {
				$queryParams['search'] = htmlspecialchars_decode($params['search'], ENT_QUOTES);
			}
			if (!empty($params['state']) && is_array($params['state'])) {
				$queryParams['state'] = (int)array_sum($params['state']);
			}
			if (empty($params['token']) || !is_string($params['token'])) {
				$queryParams['token'] = FreshRSS_UserQuery::generateToken($name);
			} else {
				$queryParams['token'] = $params['token'];
			}
			$queryParams['url'] = Minz_Url::display(['params' => $queryParams]);
			$queryParams['name'] = $name;
			if (!empty($params['description']) && is_string($params['description'])) {
				$queryParams['description'] = htmlspecialchars_decode($params['description'], ENT_QUOTES);
			}
			if (!empty($params['imageUrl']) && is_string($params['imageUrl'])) {
				$queryParams['imageUrl'] = $params['imageUrl'];
			}
			if (!empty($params['shareOpml']) && ctype_digit($params['shareOpml'])) {
				$queryParams['shareOpml'] = (bool)$params['shareOpml'];
			}
			if (!empty($params['shareRss']) && ctype_digit($params['shareRss'])) {
				$queryParams['shareRss'] = (bool)$params['shareRss'];
			}

			$queries = FreshRSS_Context::userConf()->queries;
			$queries[$id] = (new FreshRSS_UserQuery($queryParams, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
			FreshRSS_Context::userConf()->queries = $queries;
			FreshRSS_Context::userConf()->save();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'queries', 'params' => ['id' => (string)$id] ]);
		}

		FreshRSS_View::prependTitle($query->getName() . ' · ' . _t('conf.query.title') . ' · ');
	}

	/**
	 * Handles query deletion
	 */
	public function deleteQueryAction(): void {
		$id = Minz_Request::paramInt('id');
		if (Minz_Request::paramTernary('id') === null || empty(FreshRSS_Context::userConf()->queries[$id])) {
			Minz_Error::error(404);
			return;
		}

		$queries = FreshRSS_Context::userConf()->queries;
		unset($queries[$id]);
		FreshRSS_Context::userConf()->queries = $queries;
		FreshRSS_Context::userConf()->save();

		Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'queries' ]);
	}

	/**
	 * This action handles the creation of a user query.
	 *
	 * It gets the GET parameters and stores them in the configuration query
	 * storage. Before it is saved, the unwanted parameters are unset to keep
	 * lean data.
	 */
	public function bookmarkQueryAction(): void {
		$queries = [];
		foreach (FreshRSS_Context::userConf()->queries as $key => $query) {
			$queries[$key] = (new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
		}
		$params = $_GET;
		unset($params['name']);
		unset($params['rid']);
		$params['url'] = Minz_Url::display(['params' => $params]);
		$params['name'] = _t('conf.query.number', count($queries) + 1);
		$queries[] = (new FreshRSS_UserQuery($params, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();

		FreshRSS_Context::userConf()->queries = $queries;
		FreshRSS_Context::userConf()->save();

		Minz_Request::good(_t('feedback.conf.query_created', $params['name']), [ 'c' => 'configure', 'a' => 'queries' ]);
	}

	/**
	 * This action handles the system configuration page.
	 *
	 * It displays the system configuration page.
	 * If this action is reach through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The options available on the page are:
	 *   - instance name (default: FreshRSS)
	 *   - auto update URL (default: false)
	 *   - force emails validation (default: false)
	 *   - user limit (default: 1)
	 *   - user category limit (default: 16384)
	 *   - user feed limit (default: 16384)
	 *   - user login duration for form auth (default: FreshRSS_Auth::DEFAULT_COOKIE_DURATION)
	 *
	 * The `force-email-validation` is ignored with PHP < 5.5
	 */
	public function systemAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		if (Minz_Request::isPost()) {
			$limits = FreshRSS_Context::systemConf()->limits;
			$limits['max_registrations'] = Minz_Request::paramInt('max-registrations') ?: 1;
			$limits['max_feeds'] = Minz_Request::paramInt('max-feeds') ?: 16384;
			$limits['max_categories'] = Minz_Request::paramInt('max-categories') ?: 16384;
			$limits['cookie_duration'] = Minz_Request::paramInt('cookie-duration') ?: FreshRSS_Auth::DEFAULT_COOKIE_DURATION;
			FreshRSS_Context::systemConf()->limits = $limits;
			FreshRSS_Context::systemConf()->title = Minz_Request::paramString('instance-name') ?: 'FreshRSS';
			FreshRSS_Context::systemConf()->auto_update_url = Minz_Request::paramString('auto-update-url');
			FreshRSS_Context::systemConf()->force_email_validation = Minz_Request::paramBoolean('force-email-validation');
			FreshRSS_Context::systemConf()->save();

			invalidateHttpCache();

			Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'system' ]);
		}
	}
}
entryController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/entryController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle every entry actions.
 */
class FreshRSS_entry_Controller extends FreshRSS_ActionController {

	/**
	 * JavaScript request or not.
	 */
	private bool $ajax = false;

	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		// If ajax request, we do not print layout
		$this->ajax = Minz_Request::paramBoolean('ajax');
		if ($this->ajax) {
			$this->view->_layout(null);
			Minz_Request::_param('ajax');
		}
	}

	/**
	 * Mark one or several entries as read (or not!).
	 *
	 * If request concerns several entries, it MUST be a POST request.
	 * If request concerns several entries, only mark them as read is available.
	 *
	 * Parameters are:
	 *   - id (default: false)
	 *   - get (default: false) /(c_\d+|f_\d+|s|a)/
	 *   - nextGet (default: $get)
	 *   - idMax (default: 0)
	 *   - is_read (default: true)
	 */
	public function readAction(): void {
		$get = Minz_Request::paramString('get');
		$next_get = Minz_Request::paramString('nextGet') ?: $get;
		$id_max = Minz_Request::paramString('idMax') ?: '0';
		if (!ctype_digit($id_max)) {
			$id_max = '0';
		}
		$is_read = Minz_Request::paramTernary('is_read') ?? true;
		FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));

		FreshRSS_Context::$state = Minz_Request::paramInt('state');
		if (FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_FAVORITE)) {
			if (!FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_NOT_FAVORITE)) {
				FreshRSS_Context::$state = FreshRSS_Entry::STATE_FAVORITE;
			}
		} elseif (FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_NOT_FAVORITE)) {
			FreshRSS_Context::$state = FreshRSS_Entry::STATE_NOT_FAVORITE;
		} else {
			FreshRSS_Context::$state = 0;
		}

		$params = [];
		$this->view->tagsForEntries = [];

		$entryDAO = FreshRSS_Factory::createEntryDao();
		if (!Minz_Request::hasParam('id')) {
			// No id, then it MUST be a POST request
			if (!Minz_Request::isPost()) {
				Minz_Request::bad(_t('feedback.access.not_found'), ['c' => 'index', 'a' => 'index']);
				return;
			}

			if ($get === '') {
				// No get? Mark all entries as read (from $id_max)
				$entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Feed::PRIORITY_IMPORTANT, null, 0, $is_read);
			} else {
				$type_get = $get[0];
				$get = (int)substr($get, 2);
				switch ($type_get) {
					case 'c':
						$entryDAO->markReadCat($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 'f':
						$entryDAO->markReadFeed($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 's':
						$entryDAO->markReadEntries($id_max, true, null, FreshRSS_Feed::PRIORITY_IMPORTANT,
							FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 'a':
						$entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Feed::PRIORITY_IMPORTANT,
							FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 'i':
						$entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_IMPORTANT, null,
							FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 't':
						$entryDAO->markReadTag($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
					case 'T':
						$entryDAO->markReadTag(0, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
						break;
				}

				if ($next_get !== 'a') {
					// Redirect to the correct page (category, feed or starred)
					// Not "a" because it is the default value if nothing is given.
					$params['get'] = $next_get;
				}
			}
		} else {
			/** @var array<numeric-string> $idArray */
			$idArray = Minz_Request::paramArrayString('id');
			$idString = Minz_Request::paramString('id');
			if (count($idArray) > 0) {
				$ids = $idArray;
			} elseif (ctype_digit($idString)) {
				$ids = [$idString];
			} else {
				$ids = [];
			}
			$entryDAO->markRead($ids, $is_read);
			$tagDAO = FreshRSS_Factory::createTagDao();
			$tagsForEntries = $tagDAO->getTagsForEntries($ids) ?: [];
			$tags = [];
			foreach ($tagsForEntries as $line) {
				$tags['t_' . $line['id_tag']][] = $line['id_entry'];
			}
			$this->view->tagsForEntries = $tags;
		}

		if (!$this->ajax) {
			Minz_Request::good(
				$is_read ? _t('feedback.sub.articles.marked_read') : _t('feedback.sub.articles.marked_unread'),
				[
					'c' => 'index',
					'a' => 'index',
					'params' => $params,
				]
			);
		}
	}

	/**
	 * This action marks an entry as favourite (bookmark) or not.
	 *
	 * Parameter is:
	 *   - id (default: false)
	 *   - is_favorite (default: true)
	 * If id is false, nothing happened.
	 */
	public function bookmarkAction(): void {
		$id = Minz_Request::paramString('id');
		$is_favourite = Minz_Request::paramTernary('is_favorite') ?? true;
		if ($id != '' && ctype_digit($id)) {
			$entryDAO = FreshRSS_Factory::createEntryDao();
			$entryDAO->markFavorite($id, $is_favourite);
		}

		if (!$this->ajax) {
			Minz_Request::forward([
				'c' => 'index',
				'a' => 'index',
			], true);
		}
	}

	/**
	 * This action optimizes database to reduce its size.
	 *
	 * This action should be reached by a POST request.
	 *
	 * @todo move this action in configure controller.
	 * @todo call this action through web-cron when available
	 */
	public function optimizeAction(): void {
		$url_redirect = [
			'c' => 'configure',
			'a' => 'archiving',
		];

		if (!Minz_Request::isPost()) {
			Minz_Request::forward($url_redirect, true);
		}

		if (function_exists('set_time_limit')) {
			@set_time_limit(300);
		}

		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
		$databaseDAO->optimize();

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$feedDAO->updateCachedValues();

		invalidateHttpCache();
		Minz_Request::good(_t('feedback.admin.optimization_complete'), $url_redirect);
	}

	/**
	 * This action purges old entries from feeds.
	 *
	 * @todo should be a POST request
	 * @todo should be in feedController
	 */
	public function purgeAction(): void {
		if (function_exists('set_time_limit')) {
			@set_time_limit(300);
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$feeds = $feedDAO->listFeeds();
		$nb_total = 0;

		invalidateHttpCache();

		$feedDAO->beginTransaction();

		foreach ($feeds as $feed) {
			$nb_total += ($feed->cleanOldEntries() ?: 0);
		}

		$feedDAO->updateCachedValues();
		$feedDAO->commit();

		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
		$databaseDAO->minorDbMaintenance();

		invalidateHttpCache();
		Minz_Request::good(_t('feedback.sub.purge_completed', $nb_total), [
			'c' => 'configure',
			'a' => 'archiving',
		]);
	}
}
errorController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/errorController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle error page.
 */
class FreshRSS_error_Controller extends FreshRSS_ActionController {
	/**
	 * This action is the default one for the controller.
	 *
	 * It is called by Minz_Error::error() method.
	 *
	 * Parameters are passed by Minz_Session to have a proper url:
	 *   - error_code (default: 404)
	 *   - error_logs (default: array())
	 */
	public function indexAction(): void {
		$code_int = Minz_Session::paramInt('error_code') ?: 404;
		/** @var array<string> */
		$error_logs = Minz_Session::paramArray('error_logs');
		Minz_Session::_params([
			'error_code' => false,
			'error_logs' => false,
		]);

		switch ($code_int) {
			case 200:
				header('HTTP/1.1 200 OK');
				break;
			case 400:
				header('HTTP/1.1 400 Bad Request');
				$this->view->code = 'Error 400 - Bad Request';
				$this->view->errorMessage = '';
				break;
			case 403:
				header('HTTP/1.1 403 Forbidden');
				$this->view->code = 'Error 403 - Forbidden';
				$this->view->errorMessage = _t('feedback.access.denied');
				break;
			case 404:
				header('HTTP/1.1 404 Not Found');
				$this->view->code = 'Error 404 - Not found';
				$this->view->errorMessage = _t('feedback.access.not_found');
				break;
			case 405:
				header('HTTP/1.1 405 Method Not Allowed');
				$this->view->code = 'Error 405 - Method Not Allowed';
				$this->view->errorMessage = '';
				break;
			case 503:
				header('HTTP/1.1 503 Service Unavailable');
				$this->view->code = 'Error 503 - Service Unavailable';
				$this->view->errorMessage = 'Error 503 - Service Unavailable';
				break;
			case 500:
			default:
				header('HTTP/1.1 500 Internal Server Error');
				$this->view->code = 'Error 500 - Internal Server Error';
				$this->view->errorMessage = 'Error 500 - Internal Server Error';
				break;
		}

		$error_message = trim(implode($error_logs));
		if ($error_message !== '') {
			$this->view->errorMessage = $error_message;
		}

		FreshRSS_View::prependTitle($this->view->code . ' · ');
	}
}
extensionController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/extensionController.php'
View Content
<?php
declare(strict_types=1);

/**
 * The controller to manage extensions.
 */
class FreshRSS_extension_Controller extends FreshRSS_ActionController {
	/**
	 * This action is called before every other action in that class. It is
	 * the common boiler plate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
	}

	/**
	 * This action lists all the extensions available to the current user.
	 */
	public function indexAction(): void {
		FreshRSS_View::prependTitle(_t('admin.extensions.title') . ' · ');
		$this->view->extension_list = [
			'system' => [],
			'user' => [],
		];

		$this->view->extensions_installed = [];

		$extensions = Minz_ExtensionManager::listExtensions();
		foreach ($extensions as $ext) {
			$this->view->extension_list[$ext->getType()][] = $ext;
			$this->view->extensions_installed[$ext->getEntrypoint()] = $ext->getVersion();
		}

		$this->view->available_extensions = $this->getAvailableExtensionList();
	}

	/**
	 * fetch extension list from GitHub
	 * @return array<array{'name':string,'author':string,'description':string,'version':string,'entrypoint':string,'type':'system'|'user','url':string,'method':string,'directory':string}>
	 */
	protected function getAvailableExtensionList(): array {
		$extensionListUrl = 'https://raw.githubusercontent.com/FreshRSS/Extensions/master/extensions.json';
		$json = @file_get_contents($extensionListUrl);

		// we ran into problems, simply ignore them
		if ($json === false) {
			Minz_Log::error('Could not fetch available extension from GitHub');
			return [];
		}

		// fetch the list as an array
		/** @var array<string,mixed> $list*/
		$list = json_decode($json, true);
		if (!is_array($list) || empty($list['extensions']) || !is_array($list['extensions'])) {
			Minz_Log::warning('Failed to convert extension file list');
			return [];
		}

		// By now, all the needed data is kept in the main extension file.
		// In the future we could fetch detail information from the extensions metadata.json, but I tend to stick with
		// the current implementation for now, unless it becomes too much effort maintain the extension list manually
		$extensions = [];
		foreach ($list['extensions'] as $extension) {
			if (isset($extension['version']) && is_numeric($extension['version'])) {
				$extension['version'] = (string)$extension['version'];
			}
			foreach (['author', 'description', 'directory', 'entrypoint', 'method', 'name', 'type', 'url', 'version'] as $key) {
				if (empty($extension[$key]) || !is_string($extension[$key])) {
					continue 2;
				}
			}
			if (!in_array($extension['type'], ['system', 'user'], true)) {
				continue;
			}
			$extensions[] = $extension;
		}
		return $extensions;
	}

	/**
	 * This action handles configuration of a given extension.
	 *
	 * Only administrator can configure a system extension.
	 *
	 * Parameters are:
	 * - e: the extension name (urlencoded)
	 * - additional parameters which should be handle by the extension
	 *   handleConfigureAction() method (POST request).
	 */
	public function configureAction(): void {
		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		} elseif (Minz_Request::paramBoolean('slider')) {
			$this->indexAction();
			$this->view->_path('extension/index.phtml');
		}

		$ext_name = urldecode(Minz_Request::paramString('e'));
		$ext = Minz_ExtensionManager::findExtension($ext_name);

		if ($ext === null) {
			Minz_Error::error(404);
			return;
		}
		if ($ext->getType() === 'system' && !FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
			return;
		}

		FreshRSS_View::prependTitle($ext->getName() . ' · ' . _t('admin.extensions.title') . ' · ');
		$this->view->extension = $ext;
		$this->view->extension->handleConfigureAction();
	}

	/**
	 * This action enables a disabled extension for the current user.
	 *
	 * System extensions can only be enabled by an administrator.
	 * This action must be reached by a POST request.
	 *
	 * Parameter is:
	 * - e: the extension name (urlencoded).
	 */
	public function enableAction(): void {
		$url_redirect = ['c' => 'extension', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			$ext_name = urldecode(Minz_Request::paramString('e'));
			$ext = Minz_ExtensionManager::findExtension($ext_name);

			if (is_null($ext)) {
				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name), $url_redirect);
				return;
			}

			if ($ext->isEnabled()) {
				Minz_Request::bad(_t('feedback.extensions.already_enabled', $ext_name), $url_redirect);
			}

			$type = $ext->getType();
			if ($type !== 'user' && !FreshRSS_Auth::hasAccess('admin')) {
				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name), $url_redirect);
				return;
			}

			$conf = null;
			if ($type === 'system') {
				$conf = FreshRSS_Context::systemConf();
			} elseif ($type === 'user') {
				$conf = FreshRSS_Context::userConf();
			}

			$res = $ext->install();

			if ($conf !== null && $res === true) {
				$ext_list = $conf->extensions_enabled;
				$ext_list = array_filter($ext_list, static function (string $key) use ($type) {
					// Remove from list the extensions that have disappeared or changed type
					$extension = Minz_ExtensionManager::findExtension($key);
					return $extension !== null && $extension->getType() === $type;
				}, ARRAY_FILTER_USE_KEY);

				$ext_list[$ext_name] = true;
				$conf->extensions_enabled = $ext_list;
				$conf->save();

				Minz_Request::good(_t('feedback.extensions.enable.ok', $ext_name), $url_redirect);
			} else {
				Minz_Log::warning('Cannot enable extension ' . $ext_name . ': ' . $res);
				Minz_Request::bad(_t('feedback.extensions.enable.ko', $ext_name, _url('index', 'logs')), $url_redirect);
			}
		}

		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * This action disables an enabled extension for the current user.
	 *
	 * System extensions can only be disabled by an administrator.
	 * This action must be reached by a POST request.
	 *
	 * Parameter is:
	 * - e: the extension name (urlencoded).
	 */
	public function disableAction(): void {
		$url_redirect = ['c' => 'extension', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			$ext_name = urldecode(Minz_Request::paramString('e'));
			$ext = Minz_ExtensionManager::findExtension($ext_name);

			if (is_null($ext)) {
				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name), $url_redirect);
				return;
			}

			if (!$ext->isEnabled()) {
				Minz_Request::bad(_t('feedback.extensions.not_enabled', $ext_name), $url_redirect);
			}

			$type = $ext->getType();
			if ($type !== 'user' && !FreshRSS_Auth::hasAccess('admin')) {
				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name), $url_redirect);
				return;
			}

			$conf = null;
			if ($type === 'system') {
				$conf = FreshRSS_Context::systemConf();
			} elseif ($type === 'user') {
				$conf = FreshRSS_Context::userConf();
			}

			$res = $ext->uninstall();

			if ($conf !== null && $res === true) {
				$ext_list = $conf->extensions_enabled;
				$ext_list = array_filter($ext_list, static function (string $key) use ($type) {
					// Remove from list the extensions that have disappeared or changed type
					$extension = Minz_ExtensionManager::findExtension($key);
					return $extension !== null && $extension->getType() === $type;
				}, ARRAY_FILTER_USE_KEY);

				$ext_list[$ext_name] = false;
				$conf->extensions_enabled = $ext_list;
				$conf->save();

				Minz_Request::good(_t('feedback.extensions.disable.ok', $ext_name), $url_redirect);
			} else {
				Minz_Log::warning('Cannot disable extension ' . $ext_name . ': ' . $res);
				Minz_Request::bad(_t('feedback.extensions.disable.ko', $ext_name, _url('index', 'logs')), $url_redirect);
			}
		}

		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * This action handles deletion of an extension.
	 *
	 * Only administrator can remove an extension.
	 * This action must be reached by a POST request.
	 *
	 * Parameter is:
	 * -e: extension name (urlencoded)
	 */
	public function removeAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		$url_redirect = ['c' => 'extension', 'a' => 'index'];

		if (Minz_Request::isPost()) {
			$ext_name = urldecode(Minz_Request::paramString('e'));
			$ext = Minz_ExtensionManager::findExtension($ext_name);

			if (is_null($ext)) {
				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name), $url_redirect);
				return;
			}

			$res = recursive_unlink($ext->getPath());
			if ($res) {
				Minz_Request::good(_t('feedback.extensions.removed', $ext_name), $url_redirect);
			} else {
				Minz_Request::bad(_t('feedback.extensions.cannot_remove', $ext_name), $url_redirect);
			}
		}

		Minz_Request::forward($url_redirect, true);
	}
}
feedController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/feedController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle every feed actions.
 */
class FreshRSS_feed_Controller extends FreshRSS_ActionController {
	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			// Token is useful in the case that anonymous refresh is forbidden
			// and CRON task cannot be used with php command so the user can
			// set a CRON task to refresh his feeds by using token inside url
			$token = FreshRSS_Context::userConf()->token;
			$token_param = Minz_Request::paramString('token');
			$token_is_ok = ($token != '' && $token == $token_param);
			$action = Minz_Request::actionName();
			$allow_anonymous_refresh = FreshRSS_Context::systemConf()->allow_anonymous_refresh;
			if ($action !== 'actualize' ||
					!($allow_anonymous_refresh || $token_is_ok)) {
				Minz_Error::error(403);
			}
		}
	}

	/**
	 * @param array<string,mixed> $attributes
	 * @throws FreshRSS_AlreadySubscribed_Exception
	 * @throws FreshRSS_BadUrl_Exception
	 * @throws FreshRSS_Feed_Exception
	 * @throws FreshRSS_FeedNotAdded_Exception
	 * @throws Minz_FileNotExistException
	 */
	public static function addFeed(string $url, string $title = '', int $cat_id = 0, string $new_cat_name = '',
		string $http_auth = '', array $attributes = [], int $kind = FreshRSS_Feed::KIND_RSS): FreshRSS_Feed {
		FreshRSS_UserDAO::touch();
		if (function_exists('set_time_limit')) {
			@set_time_limit(300);
		}

		$catDAO = FreshRSS_Factory::createCategoryDao();

		$url = trim($url);

		/** @var string|null $urlHooked */
		$urlHooked = Minz_ExtensionManager::callHook('check_url_before_add', $url);
		if ($urlHooked === null) {
			throw new FreshRSS_FeedNotAdded_Exception($url);
		}
		$url = $urlHooked;

		$cat = null;
		if ($cat_id > 0) {
			$cat = $catDAO->searchById($cat_id);
		}
		if ($cat === null && $new_cat_name != '') {
			$new_cat_id = $catDAO->addCategory(['name' => $new_cat_name]);
			$cat_id = $new_cat_id > 0 ? $new_cat_id : $cat_id;
			$cat = $catDAO->searchById($cat_id);
		}
		if ($cat === null) {
			$catDAO->checkDefault();
		}

		$feed = new FreshRSS_Feed($url);	//Throws FreshRSS_BadUrl_Exception
		$title = trim($title);
		if ($title !== '') {
			$feed->_name($title);
		}
		$feed->_kind($kind);
		$feed->_attributes($attributes);
		$feed->_httpAuth($http_auth);
		if ($cat === null) {
			$feed->_categoryId(FreshRSS_CategoryDAO::DEFAULTCATEGORYID);
		} else {
			$feed->_category($cat);
		}
		switch ($kind) {
			case FreshRSS_Feed::KIND_RSS:
			case FreshRSS_Feed::KIND_RSS_FORCED:
				$feed->load(true);	//Throws FreshRSS_Feed_Exception, Minz_FileNotExistException
				break;
			case FreshRSS_Feed::KIND_HTML_XPATH:
			case FreshRSS_Feed::KIND_XML_XPATH:
				$feed->_website($url);
				break;
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		if ($feedDAO->searchByUrl($feed->url())) {
			throw new FreshRSS_AlreadySubscribed_Exception($url, $feed->name());
		}

		/** @var FreshRSS_Feed|null $feed */
		$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
		if ($feed === null) {
			throw new FreshRSS_FeedNotAdded_Exception($url);
		}

		$id = $feedDAO->addFeedObject($feed);
		if (!$id) {
			// There was an error in database… we cannot say what here.
			throw new FreshRSS_FeedNotAdded_Exception($url);
		}
		$feed->_id($id);

		// Ok, feed has been added in database. Now we have to refresh entries.
		self::actualizeFeedsAndCommit($id, $url);
		return $feed;
	}

	/**
	 * This action subscribes to a feed.
	 *
	 * It can be reached by both GET and POST requests.
	 *
	 * GET request displays a form to add and configure a feed.
	 * Request parameter is:
	 *   - url_rss (default: false)
	 *
	 * POST request adds a feed in database.
	 * Parameters are:
	 *   - url_rss (default: false)
	 *   - category (default: false)
	 *   - http_user (default: false)
	 *   - http_pass (default: false)
	 * It tries to get website information from RSS feed.
	 * If no category is given, feed is added to the default one.
	 *
	 * If url_rss is false, nothing happened.
	 */
	public function addAction(): void {
		$url = Minz_Request::paramString('url_rss');

		if ($url === '') {
			// No url, do nothing
			Minz_Request::forward([
				'c' => 'subscription',
				'a' => 'index',
			], true);
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$url_redirect = [
			'c' => 'subscription',
			'a' => 'add',
			'params' => [],
		];

		$limits = FreshRSS_Context::systemConf()->limits;
		$this->view->feeds = $feedDAO->listFeeds();
		if (count($this->view->feeds) >= $limits['max_feeds']) {
			Minz_Request::bad(_t('feedback.sub.feed.over_max', $limits['max_feeds']), $url_redirect);
		}

		if (Minz_Request::isPost()) {
			$cat = Minz_Request::paramInt('category');

			// HTTP information are useful if feed is protected behind a
			// HTTP authentication
			$user = Minz_Request::paramString('http_user');
			$pass = Minz_Request::paramString('http_pass');
			$http_auth = '';
			if ($user != '' && $pass != '') {	//TODO: Sanitize
				$http_auth = $user . ':' . $pass;
			}

			$cookie = Minz_Request::paramString('curl_params_cookie');
			$cookie_file = Minz_Request::paramBoolean('curl_params_cookiefile');
			$max_redirs = Minz_Request::paramInt('curl_params_redirects');
			$useragent = Minz_Request::paramString('curl_params_useragent');
			$proxy_address = Minz_Request::paramString('curl_params');
			$proxy_type = Minz_Request::paramString('proxy_type');
			$request_method = Minz_Request::paramString('curl_method');
			$request_fields = Minz_Request::paramString('curl_fields', true);

			$opts = [];
			if ($proxy_type !== '') {
				$opts[CURLOPT_PROXY] = $proxy_address;
				$opts[CURLOPT_PROXYTYPE] = (int)$proxy_type;
			}
			if ($cookie !== '') {
				$opts[CURLOPT_COOKIE] = $cookie;
			}
			if ($cookie_file) {
				// Pass empty cookie file name to enable the libcurl cookie engine
				// without reading any existing cookie data.
				$opts[CURLOPT_COOKIEFILE] = '';
			}
			if ($max_redirs !== 0) {
				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
				$opts[CURLOPT_FOLLOWLOCATION] = 1;
			}
			if ($useragent !== '') {
				$opts[CURLOPT_USERAGENT] = $useragent;
			}
			if ($request_method === 'POST') {
				$opts[CURLOPT_POST] = true;
				if ($request_fields !== '') {
					$opts[CURLOPT_POSTFIELDS] = $request_fields;
					if (json_decode($request_fields, true) !== null) {
						$opts[CURLOPT_HTTPHEADER] = ['Content-Type: application/json'];
					}
				}
			}

			$attributes = [
				'curl_params' => empty($opts) ? null : $opts,
			];
			$attributes['ssl_verify'] = Minz_Request::paramTernary('ssl_verify');
			$timeout = Minz_Request::paramInt('timeout');
			$attributes['timeout'] = $timeout > 0 ? $timeout : null;

			$feed_kind = Minz_Request::paramInt('feed_kind') ?: FreshRSS_Feed::KIND_RSS;
			if ($feed_kind === FreshRSS_Feed::KIND_HTML_XPATH || $feed_kind === FreshRSS_Feed::KIND_XML_XPATH) {
				$xPathSettings = [];
				if (Minz_Request::paramString('xPathFeedTitle') !== '') {
					$xPathSettings['feedTitle'] = Minz_Request::paramString('xPathFeedTitle', true);
				}
				if (Minz_Request::paramString('xPathItem') !== '') {
					$xPathSettings['item'] = Minz_Request::paramString('xPathItem', true);
				}
				if (Minz_Request::paramString('xPathItemTitle') !== '') {
					$xPathSettings['itemTitle'] = Minz_Request::paramString('xPathItemTitle', true);
				}
				if (Minz_Request::paramString('xPathItemContent') !== '') {
					$xPathSettings['itemContent'] = Minz_Request::paramString('xPathItemContent', true);
				}
				if (Minz_Request::paramString('xPathItemUri') !== '') {
					$xPathSettings['itemUri'] = Minz_Request::paramString('xPathItemUri', true);
				}
				if (Minz_Request::paramString('xPathItemAuthor') !== '') {
					$xPathSettings['itemAuthor'] = Minz_Request::paramString('xPathItemAuthor', true);
				}
				if (Minz_Request::paramString('xPathItemTimestamp') !== '') {
					$xPathSettings['itemTimestamp'] = Minz_Request::paramString('xPathItemTimestamp', true);
				}
				if (Minz_Request::paramString('xPathItemTimeFormat') !== '') {
					$xPathSettings['itemTimeFormat'] = Minz_Request::paramString('xPathItemTimeFormat', true);
				}
				if (Minz_Request::paramString('xPathItemThumbnail') !== '') {
					$xPathSettings['itemThumbnail'] = Minz_Request::paramString('xPathItemThumbnail', true);
				}
				if (Minz_Request::paramString('xPathItemCategories') !== '') {
					$xPathSettings['itemCategories'] = Minz_Request::paramString('xPathItemCategories', true);
				}
				if (Minz_Request::paramString('xPathItemUid') !== '') {
					$xPathSettings['itemUid'] = Minz_Request::paramString('xPathItemUid', true);
				}
				if (!empty($xPathSettings)) {
					$attributes['xpath'] = $xPathSettings;
				}
			} elseif ($feed_kind === FreshRSS_Feed::KIND_JSON_DOTNOTATION) {
				$jsonSettings = [];
				if (Minz_Request::paramString('jsonFeedTitle') !== '') {
					$jsonSettings['feedTitle'] = Minz_Request::paramString('jsonFeedTitle', true);
				}
				if (Minz_Request::paramString('jsonItem') !== '') {
					$jsonSettings['item'] = Minz_Request::paramString('jsonItem', true);
				}
				if (Minz_Request::paramString('jsonItemTitle') !== '') {
					$jsonSettings['itemTitle'] = Minz_Request::paramString('jsonItemTitle', true);
				}
				if (Minz_Request::paramString('jsonItemContent') !== '') {
					$jsonSettings['itemContent'] = Minz_Request::paramString('jsonItemContent', true);
				}
				if (Minz_Request::paramString('jsonItemUri') !== '') {
					$jsonSettings['itemUri'] = Minz_Request::paramString('jsonItemUri', true);
				}
				if (Minz_Request::paramString('jsonItemAuthor') !== '') {
					$jsonSettings['itemAuthor'] = Minz_Request::paramString('jsonItemAuthor', true);
				}
				if (Minz_Request::paramString('jsonItemTimestamp') !== '') {
					$jsonSettings['itemTimestamp'] = Minz_Request::paramString('jsonItemTimestamp', true);
				}
				if (Minz_Request::paramString('jsonItemTimeFormat') !== '') {
					$jsonSettings['itemTimeFormat'] = Minz_Request::paramString('jsonItemTimeFormat', true);
				}
				if (Minz_Request::paramString('jsonItemThumbnail') !== '') {
					$jsonSettings['itemThumbnail'] = Minz_Request::paramString('jsonItemThumbnail', true);
				}
				if (Minz_Request::paramString('jsonItemCategories') !== '') {
					$jsonSettings['itemCategories'] = Minz_Request::paramString('jsonItemCategories', true);
				}
				if (Minz_Request::paramString('jsonItemUid') !== '') {
					$jsonSettings['itemUid'] = Minz_Request::paramString('jsonItemUid', true);
				}
				if (!empty($jsonSettings)) {
					$attributes['json_dotnotation'] = $jsonSettings;
				}
			}

			try {
				$feed = self::addFeed($url, '', $cat, '', $http_auth, $attributes, $feed_kind);
			} catch (FreshRSS_BadUrl_Exception $e) {
				// Given url was not a valid url!
				Minz_Log::warning($e->getMessage());
				Minz_Request::bad(_t('feedback.sub.feed.invalid_url', $url), $url_redirect);
				return;
			} catch (FreshRSS_Feed_Exception $e) {
				// Something went bad (timeout, server not found, etc.)
				Minz_Log::warning($e->getMessage());
				Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
				return;
			} catch (Minz_FileNotExistException $e) {
				// Cache directory doesn’t exist!
				Minz_Log::error($e->getMessage());
				Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
				return;
			} catch (FreshRSS_AlreadySubscribed_Exception $e) {
				Minz_Request::bad(_t('feedback.sub.feed.already_subscribed', $e->feedName()), $url_redirect);
				return;
			} catch (FreshRSS_FeedNotAdded_Exception $e) {
				Minz_Request::bad(_t('feedback.sub.feed.not_added', $e->url()), $url_redirect);
				return;
			}

			// Entries are in DB, we redirect to feed configuration page.
			$url_redirect['a'] = 'feed';
			$url_redirect['params']['id'] = '' . $feed->id();
			Minz_Request::good(_t('feedback.sub.feed.added', $feed->name()), $url_redirect);
		} else {
			// GET request: we must ask confirmation to user before adding feed.
			FreshRSS_View::prependTitle(_t('sub.feed.title_add') . ' · ');

			$catDAO = FreshRSS_Factory::createCategoryDao();
			$this->view->categories = $catDAO->listCategories(false) ?: [];
			$this->view->feed = new FreshRSS_Feed($url);
			try {
				// We try to get more information about the feed.
				$this->view->feed->load(true);
				$this->view->load_ok = true;
			} catch (Exception $e) {
				$this->view->load_ok = false;
			}

			$feed = $feedDAO->searchByUrl($this->view->feed->url());
			if ($feed) {
				// Already subscribe so we redirect to the feed configuration page.
				$url_redirect['a'] = 'feed';
				$url_redirect['params']['id'] = $feed->id();
				Minz_Request::good(_t('feedback.sub.feed.already_subscribed', $feed->name()), $url_redirect);
			}
		}
	}

	/**
	 * This action remove entries from a given feed.
	 *
	 * It should be reached by a POST action.
	 *
	 * Parameter is:
	 *   - id (default: false)
	 */
	public function truncateAction(): void {
		$id = Minz_Request::paramInt('id');
		$url_redirect = [
			'c' => 'subscription',
			'a' => 'index',
			'params' => ['id' => $id],
		];

		if (!Minz_Request::isPost()) {
			Minz_Request::forward($url_redirect, true);
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$n = $feedDAO->truncate($id);

		invalidateHttpCache();
		if ($n === false) {
			Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
		} else {
			Minz_Request::good(_t('feedback.sub.feed.n_entries_deleted', $n), $url_redirect);
		}
	}

	/**
	 * @return array{0:int,1:FreshRSS_Feed|null,2:int,3:array<FreshRSS_Feed>} Number of updated feeds, first feed or null, number of new articles,
	 * 	list of feeds for which a cache refresh is needed
	 * @throws FreshRSS_BadUrl_Exception
	 */
	public static function actualizeFeeds(?int $feed_id = null, ?string $feed_url = null, ?int $maxFeeds = null, ?SimplePie $simplePiePush = null): array {
		if (function_exists('set_time_limit')) {
			@set_time_limit(300);
		}

		if (!is_int($feed_id) || $feed_id <= 0) {
			$feed_id = null;
		}
		if (!is_string($feed_url) || trim($feed_url) === '') {
			$feed_url = null;
		}
		if (!is_int($maxFeeds) || $maxFeeds <= 0) {
			$maxFeeds = PHP_INT_MAX;
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$entryDAO = FreshRSS_Factory::createEntryDao();

		// Create a list of feeds to actualize.
		$feeds = [];
		if ($feed_id !== null || $feed_url !== null) {
			$feed = $feed_id !== null ? $feedDAO->searchById($feed_id) : $feedDAO->searchByUrl($feed_url);
			if ($feed !== null && $feed->id() > 0) {
				$feeds[] = $feed;
				$feed_id = $feed->id();
			}
		} else {
			$feeds = $feedDAO->listFeedsOrderUpdate(-1);

			// Hydrate category for each feed to avoid that each feed has to make an SQL request
			$categories = [];
			$catDAO = FreshRSS_Factory::createCategoryDao();
			foreach ($catDAO->listCategories(false, false) as $category) {
				$categories[$category->id()] = $category;
			}
			foreach ($feeds as $feed) {
				$category = $categories[$feed->categoryId()] ?? null;
				if ($category !== null) {
					$feed->_category($category);
				}
			}
		}

		// WebSub (PubSubHubbub) support
		$pubsubhubbubEnabledGeneral = FreshRSS_Context::systemConf()->pubsubhubbub_enabled;
		$pshbMinAge = time() - (3600 * 24);  //TODO: Make a configuration.

		$nbUpdatedFeeds = 0;
		$nbNewArticles = 0;
		$feedsCacheToRefresh = [];

		foreach ($feeds as $feed) {
			$feed = Minz_ExtensionManager::callHook('feed_before_actualize', $feed);
			if (!($feed instanceof FreshRSS_Feed)) {
				continue;
			}

			$url = $feed->url();	//For detection of HTTP 301

			$pubSubHubbubEnabled = $pubsubhubbubEnabledGeneral && $feed->pubSubHubbubEnabled();
			if ($simplePiePush === null && $feed_id === null && $pubSubHubbubEnabled && ($feed->lastUpdate() > $pshbMinAge)) {
				//$text = 'Skip pull of feed using PubSubHubbub: ' . $url;
				//Minz_Log::debug($text);
				//Minz_Log::debug($text, PSHB_LOG);
				continue;	//When PubSubHubbub is used, do not pull refresh so often
			}

			if ($feed->mute() && ($feed_id === null || $simplePiePush !== null)) {
				continue;	// If the feed is disabled, only allow refresh if manually requested for that specific feed
			}
			$mtime = $feed->cacheModifiedTime() ?: 0;
			$ttl = $feed->ttl();
			if ($ttl === FreshRSS_Feed::TTL_DEFAULT) {
				$ttl = FreshRSS_Context::userConf()->ttl_default;
			}
			if ($simplePiePush === null && $feed_id === null && (time() <= $feed->lastUpdate() + $ttl)) {
				//Too early to refresh from source, but check whether the feed was updated by another user
				$ε = 10;	// negligible offset errors in seconds
				if ($mtime <= 0 ||
					$feed->lastUpdate() + $ε >= $mtime ||
					time() + $ε >= $mtime + FreshRSS_Context::systemConf()->limits['cache_duration']) {	// is cache still valid?
					continue;	//Nothing newer from other users
				}
				Minz_Log::debug('Feed ' . $feed->url(false) . ' was updated at ' . date('c', $feed->lastUpdate()) .
					', and at ' . date('c', $mtime) . ' by another user; take advantage of newer cache.');
			}

			if (!$feed->lock()) {
				Minz_Log::notice('Feed already being actualized: ' . $feed->url(false));
				continue;
			}

			$feedIsNew = $feed->lastUpdate() <= 0;

			try {
				if ($simplePiePush !== null) {
					$simplePie = $simplePiePush;	//Used by WebSub
				} elseif ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) {
					$simplePie = $feed->loadHtmlXpath();
					if ($simplePie === null) {
						throw new FreshRSS_Feed_Exception('HTML+XPath Web scraping failed for [' . $feed->url(false) . ']');
					}
				} elseif ($feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
					$simplePie = $feed->loadHtmlXpath();
					if ($simplePie === null) {
						throw new FreshRSS_Feed_Exception('XML+XPath parsing failed for [' . $feed->url(false) . ']');
					}
				} elseif ($feed->kind() === FreshRSS_Feed::KIND_JSON_DOTNOTATION) {
					$simplePie = $feed->loadJson();
					if ($simplePie === null) {
						throw new FreshRSS_Feed_Exception('JSON dot notation parsing failed for [' . $feed->url(false) . ']');
					}
				} elseif ($feed->kind() === FreshRSS_Feed::KIND_JSONFEED) {
					$simplePie = $feed->loadJson();
					if ($simplePie === null) {
						throw new FreshRSS_Feed_Exception('JSON Feed parsing failed for [' . $feed->url(false) . ']');
					}
				} else {
					$simplePie = $feed->load(false, $feedIsNew);
				}

				if ($simplePie === null) {
					// Feed is cached and unchanged
					$newGuids = [];
					$entries = [];
					$feedIsEmpty = false;	// We do not know
					$feedIsUnchanged = true;
				} else {
					$newGuids = $feed->loadGuids($simplePie);
					$entries = $feed->loadEntries($simplePie);
					$feedIsEmpty = $simplePiePush === null && empty($newGuids);
					$feedIsUnchanged = false;
				}
				$mtime = $feed->cacheModifiedTime() ?: time();
			} catch (FreshRSS_Feed_Exception $e) {
				Minz_Log::warning($e->getMessage());
				$feedDAO->updateLastUpdate($feed->id(), true);
				if ($e->getCode() === 410) {
					// HTTP 410 Gone
					Minz_Log::warning('Muting gone feed: ' . $feed->url(false));
					$feedDAO->mute($feed->id(), true);
				}
				$feed->unlock();
				continue;
			}

			$needFeedCacheRefresh = false;
			$nbMarkedUnread = 0;

			if (count($newGuids) > 0) {
				if (!$feed->hasAttribute('read_when_same_title_in_feed')) {
					$readWhenSameTitleInFeed = (int)FreshRSS_Context::userConf()->mark_when['same_title_in_feed'];
				} elseif ($feed->attributeBoolean('read_when_same_title_in_feed') === false) {
					$readWhenSameTitleInFeed = 0;
				} else {
					$readWhenSameTitleInFeed = $feed->attributeInt('read_when_same_title_in_feed') ?? 0;
				}
				if ($readWhenSameTitleInFeed > 0) {
					$titlesAsRead = array_fill_keys($feedDAO->listTitles($feed->id(), $readWhenSameTitleInFeed), true);
				} else {
					$titlesAsRead = [];
				}

				$mark_updated_article_unread = $feed->attributeBoolean('mark_updated_article_unread') ?? FreshRSS_Context::userConf()->mark_updated_article_unread;

				// For this feed, check existing GUIDs already in database.
				$existingHashForGuids = $entryDAO->listHashForFeedGuids($feed->id(), $newGuids) ?: [];
				/** @var array<string,bool> $newGuids */
				$newGuids = [];

				// Add entries in database if possible.
				/** @var FreshRSS_Entry $entry */
				foreach ($entries as $entry) {
					if (isset($newGuids[$entry->guid()])) {
						continue;	//Skip subsequent articles with same GUID
					}
					$newGuids[$entry->guid()] = true;
					$entry->_lastSeen($mtime);

					if (isset($existingHashForGuids[$entry->guid()])) {
						$existingHash = $existingHashForGuids[$entry->guid()];
						if (strcasecmp($existingHash, $entry->hash()) !== 0) {
							//This entry already exists but has been updated
							$entry->_isUpdated(true);
							//Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->url(false) .
								//', old hash ' . $existingHash . ', new hash ' . $entry->hash());
							$entry->_isFavorite(null);	// Do not change favourite state
							$entry->_isRead($mark_updated_article_unread ? false : null);	//Change is_read according to policy.
							if ($mark_updated_article_unread) {
								Minz_ExtensionManager::callHook('entry_auto_unread', $entry, 'updated_article');
							}

							$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
							if (!($entry instanceof FreshRSS_Entry)) {
								// An extension has returned a null value, there is nothing to insert.
								continue;
							}

							// NB: Do not mark updated articles as read based on their title, as the duplicate title maybe be from the same article.
							$entry->applyFilterActions([]);
							if ($readWhenSameTitleInFeed > 0) {
								$titlesAsRead[$entry->title()] = true;
							}

							if (!$entry->isRead()) {
								$needFeedCacheRefresh = true;	//Maybe
								$nbMarkedUnread++;
							}

							// If the entry has changed, there is a good chance for the full content to have changed as well.
							$entry->loadCompleteContent(true);

							$entryDAO->updateEntry($entry->toArray());
						}
					} else {
						$entry->_isUpdated(false);
						$id = uTimeString();
						$entry->_id($id);

						$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
						if (!($entry instanceof FreshRSS_Entry)) {
							// An extension has returned a null value, there is nothing to insert.
							continue;
						}

						$entry->applyFilterActions($titlesAsRead);
						if ($readWhenSameTitleInFeed > 0) {
							$titlesAsRead[$entry->title()] = true;
						}

						$needFeedCacheRefresh = true;

						if ($pubSubHubbubEnabled && !$simplePiePush) {	//We use push, but have discovered an article by pull!
							$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' .
								SimplePie_Misc::url_remove_credentials($url) .
								' GUID ' . $entry->guid();
							Minz_Log::warning($text, PSHB_LOG);
							Minz_Log::warning($text);
							$pubSubHubbubEnabled = false;
							$feed->pubSubHubbubError(true);
						}

						if ($entryDAO->addEntry($entry->toArray(), true)) {
							$nbNewArticles++;
						}
					}
				}
				// N.B.: Applies to _entry table and not _entrytmp:
				$entryDAO->updateLastSeen($feed->id(), array_keys($newGuids), $mtime);
			} elseif ($feedIsUnchanged) {
				// Feed cache was unchanged, so mark as seen the same entries as last time
				$entryDAO->updateLastSeenUnchanged($feed->id(), $mtime);
			}
			unset($entries);

			if (rand(0, 30) === 1) {	// Remove old entries once in 30.
				$nb = $feed->cleanOldEntries();
				if ($nb > 0) {
					$needFeedCacheRefresh = true;
				}
			}

			$feedDAO->updateLastUpdate($feed->id(), false, $mtime);
			if ($simplePiePush === null) {
				// Do not call for WebSub events, as we do not know the list of articles still on the upstream feed.
				$needFeedCacheRefresh |= ($feed->markAsReadUponGone($feedIsEmpty, $mtime) != false);
			}
			if ($needFeedCacheRefresh) {
				$feedsCacheToRefresh[] = $feed;
			}

			$feedProperties = [];

			if ($pubsubhubbubEnabledGeneral && $feed->hubUrl() && $feed->selfUrl()) {	//selfUrl has priority for WebSub
				if ($feed->selfUrl() !== $url) {	// https://github.com/pubsubhubbub/PubSubHubbub/wiki/Moving-Feeds-or-changing-Hubs
					$selfUrl = checkUrl($feed->selfUrl());
					if ($selfUrl) {
						Minz_Log::debug('WebSub unsubscribe ' . $feed->url(false));
						if (!$feed->pubSubHubbubSubscribe(false)) {	//Unsubscribe
							Minz_Log::warning('Error while WebSub unsubscribing from ' . $feed->url(false));
						}
						$feed->_url($selfUrl, false);
						Minz_Log::notice('Feed ' . $url . ' canonical address moved to ' . $feed->url(false));
						$feedDAO->updateFeed($feed->id(), ['url' => $feed->url()]);
					}
				}
			} elseif ($feed->url() !== $url) {	// HTTP 301 Moved Permanently
				Minz_Log::notice('Feed ' . SimplePie_Misc::url_remove_credentials($url) .
					' moved permanently to ' .  SimplePie_Misc::url_remove_credentials($feed->url(false)));
				$feedProperties['url'] = $feed->url();
			}

			if ($simplePie != null) {
				if ($feed->name(true) === '') {
					//HTML to HTML-PRE	//ENT_COMPAT except '&'
					$name = strtr(html_only_entity_decode($simplePie->get_title()), ['<' => '&lt;', '>' => '&gt;', '"' => '&quot;']);
					$feed->_name($name);
					$feedProperties['name'] = $feed->name(false);
				}
				if (trim($feed->website()) === '') {
					$website = html_only_entity_decode($simplePie->get_link());
					$feed->_website($website == '' ? $feed->url() : $website);
					$feedProperties['website'] = $feed->website();
					$feed->faviconPrepare();
				}
				if (trim($feed->description()) === '') {
					$description = html_only_entity_decode($simplePie->get_description());
					if ($description !== '') {
						$feed->_description($description);
						$feedProperties['description'] = $feed->description();
					}
				}
			}
			if (!empty($feedProperties)) {
				$ok = $feedDAO->updateFeed($feed->id(), $feedProperties);
				if (!$ok && $feedIsNew) {
					//Cancel adding new feed in case of database error at first actualize
					$feedDAO->deleteFeed($feed->id());
					$feed->unlock();
					break;
				}
			}

			$feed->faviconPrepare();
			if ($pubsubhubbubEnabledGeneral && $feed->pubSubHubbubPrepare()) {
				Minz_Log::notice('WebSub subscribe ' . $feed->url(false));
				if (!$feed->pubSubHubbubSubscribe(true)) {	//Subscribe
					Minz_Log::warning('Error while WebSub subscribing to ' . $feed->url(false));
				}
			}
			$feed->unlock();
			$nbUpdatedFeeds++;
			unset($feed);
			gc_collect_cycles();

			if ($nbUpdatedFeeds >= $maxFeeds) {
				break;
			}
		}
		return [$nbUpdatedFeeds, reset($feeds) ?: null, $nbNewArticles, $feedsCacheToRefresh];
	}

	/**
	 * Feeds on which to apply a the keep max unreads policy, or all feeds if none specified.
	 * @return int The number of articles marked as read
	 */
	private static function keepMaxUnreads(FreshRSS_Feed ...$feeds): int {
		$affected = 0;

		if (empty($feeds)) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$feeds = $feedDAO->listFeedsOrderUpdate(-1);
		}

		foreach ($feeds as $feed) {
			$n = $feed->markAsReadMaxUnread();
			if ($n !== false && $n > 0) {
				Minz_Log::debug($n . ' unread entries exceeding max number of ' . $feed->keepMaxUnread() .  ' for [' . $feed->url(false) . ']');
				$affected += $n;
			}
		}

		return $affected;
	}

	/**
	 * Auto-add labels to new articles.
	 * @param int $nbNewEntries The number of top recent entries to process.
	 * @return int|false The number of new labels added, or false in case of error.
	 */
	private static function applyLabelActions(int $nbNewEntries) {
		$tagDAO = FreshRSS_Factory::createTagDao();
		$labels = FreshRSS_Context::labels();
		$labels = array_filter($labels, static function (FreshRSS_Tag $label) {
			return !empty($label->filtersAction('label'));
		});
		if (count($labels) <= 0) {
			return 0;
		}

		$entryDAO = FreshRSS_Factory::createEntryDao();
		/** @var array<array{id_tag:int,id_entry:string}> $applyLabels */
		$applyLabels = [];
		foreach (FreshRSS_Entry::fromTraversable($entryDAO->selectAll($nbNewEntries)) as $entry) {
			foreach ($labels as $label) {
				$label->applyFilterActions($entry, $applyLabel);
				if ($applyLabel) {
					$applyLabels[] = [
						'id_tag' => $label->id(),
						'id_entry' => $entry->id(),
					];
				}
			}
		}
		return $tagDAO->tagEntries($applyLabels);
	}

	public static function commitNewEntries(): int {
		$entryDAO = FreshRSS_Factory::createEntryDao();
		$nbNewEntries = $entryDAO->countNewEntries();
		if ($nbNewEntries > 0) {
			if ($entryDAO->commitNewEntries()) {
				self::applyLabelActions($nbNewEntries);
			}
		}
		return $nbNewEntries;
	}

	/**
	 * @return array{0:int,1:FreshRSS_Feed|null,2:int,3:array<FreshRSS_Feed>} Number of updated feeds, first feed or null, number of new articles,
	 * 	list of feeds for which a cache refresh is needed
	 * @throws FreshRSS_BadUrl_Exception
	 */
	public static function actualizeFeedsAndCommit(?int $feed_id = null, ?string $feed_url = null, ?int $maxFeeds = null, ?SimplePie $simplePiePush = null): array {
		$entryDAO = FreshRSS_Factory::createEntryDao();
		[$nbUpdatedFeeds, $feed, $nbNewArticles, $feedsCacheToRefresh] = FreshRSS_feed_Controller::actualizeFeeds($feed_id, $feed_url, $maxFeeds, $simplePiePush);
		if ($nbNewArticles > 0) {
			$entryDAO->beginTransaction();
			FreshRSS_feed_Controller::commitNewEntries();
		}
		if (count($feedsCacheToRefresh) > 0) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			self::keepMaxUnreads(...$feedsCacheToRefresh);
			$feedDAO->updateCachedValues(...array_map(fn(FreshRSS_Feed $f) => $f->id(), $feedsCacheToRefresh));
		}
		if ($entryDAO->inTransaction()) {
			$entryDAO->commit();
		}
		return [$nbUpdatedFeeds, $feed, $nbNewArticles, $feedsCacheToRefresh];
	}

	/**
	 * This action actualizes entries from one or several feeds.
	 *
	 * Parameters are:
	 *   - id (default: null): Feed ID, or set to -1 to commit new articles to the main database
	 *   - url (default: null): Feed URL (instead of feed ID)
	 *   - maxFeeds (default: 10): Max number of feeds to refresh
	 *   - noCommit (default: 0): Set to 1 to prevent committing the new articles to the main database
	 * If id and url are not specified, all the feeds are actualized, within the limits of maxFeeds.
	 */
	public function actualizeAction(): int {
		Minz_Session::_param('actualize_feeds', false);
		$id = Minz_Request::paramInt('id');
		$url = Minz_Request::paramString('url');
		$maxFeeds = Minz_Request::paramInt('maxFeeds') ?: 10;
		$noCommit = ($_POST['noCommit'] ?? 0) == 1;

		if ($id === -1 && !$noCommit) {	//Special request only to commit & refresh DB cache
			$nbUpdatedFeeds = 0;
			$feed = null;
			FreshRSS_feed_Controller::commitNewEntries();
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$feedDAO->updateCachedValues();
		} else {
			if ($id === 0 && $url === '') {
				// Case of a batch refresh (e.g. cron)
				$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
				$databaseDAO->minorDbMaintenance();
				Minz_ExtensionManager::callHookVoid('freshrss_user_maintenance');

				FreshRSS_feed_Controller::commitNewEntries();
				$feedDAO = FreshRSS_Factory::createFeedDao();
				$feedDAO->updateCachedValues();
				FreshRSS_category_Controller::refreshDynamicOpmls();
			}
			$entryDAO = FreshRSS_Factory::createEntryDao();
			[$nbUpdatedFeeds, $feed, $nbNewArticles, $feedsCacheToRefresh] = self::actualizeFeeds($id, $url, $maxFeeds);
			if (!$noCommit) {
				if ($nbNewArticles > 0) {
					$entryDAO->beginTransaction();
					FreshRSS_feed_Controller::commitNewEntries();
				}
				$feedDAO = FreshRSS_Factory::createFeedDao();
				if ($id !== 0 && $id !== -1) {
					if ($feed instanceof FreshRSS_Feed) {
						self::keepMaxUnreads($feed);
					}
					// Case of single feed refreshed, always update its cache
					$feedDAO->updateCachedValues($id);
				} elseif (count($feedsCacheToRefresh) > 0) {
					self::keepMaxUnreads(...$feedsCacheToRefresh);
					// Case of multiple feeds refreshed, only update cache of affected feeds
					$feedDAO->updateCachedValues(...array_map(fn(FreshRSS_Feed $f) => $f->id(), $feedsCacheToRefresh));
				}
			}
			if ($entryDAO->inTransaction()) {
				$entryDAO->commit();
			}
		}

		if (Minz_Request::paramBoolean('ajax')) {
			// Most of the time, ajax request is for only one feed. But since
			// there are several parallel requests, we should return that there
			// are several updated feeds.
			Minz_Request::setGoodNotification(_t('feedback.sub.feed.actualizeds'));
			// No layout in ajax request.
			$this->view->_layout(null);
		} elseif ($feed instanceof FreshRSS_Feed) {
			// Redirect to the main page with correct notification.
			Minz_Request::good(_t('feedback.sub.feed.actualized', $feed->name()), [
				'params' => ['get' => 'f_' . $id]
			]);
		} elseif ($nbUpdatedFeeds >= 1) {
			Minz_Request::good(_t('feedback.sub.feed.n_actualized', $nbUpdatedFeeds), []);
		} else {
			Minz_Request::good(_t('feedback.sub.feed.no_refresh'), []);
		}
		return $nbUpdatedFeeds;
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public static function renameFeed(int $feed_id, string $feed_name): bool {
		if ($feed_id <= 0 || $feed_name === '') {
			return false;
		}
		FreshRSS_UserDAO::touch();
		$feedDAO = FreshRSS_Factory::createFeedDao();
		return $feedDAO->updateFeed($feed_id, ['name' => $feed_name]) === 1;
	}

	public static function moveFeed(int $feed_id, int $cat_id, string $new_cat_name = ''): bool {
		if ($feed_id <= 0 || ($cat_id <= 0 && $new_cat_name === '')) {
			return false;
		}
		FreshRSS_UserDAO::touch();

		$catDAO = FreshRSS_Factory::createCategoryDao();
		if ($cat_id > 0) {
			$cat = $catDAO->searchById($cat_id);
			$cat_id = $cat === null ? 0 : $cat->id();
		}
		if ($cat_id <= 1 && $new_cat_name != '') {
			$cat_id = $catDAO->addCategory(['name' => $new_cat_name]);
		}
		if ($cat_id <= 1) {
			$catDAO->checkDefault();
			$cat_id = FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		return $feedDAO->updateFeed($feed_id, ['category' => $cat_id]) === 1;
	}

	/**
	 * This action changes the category of a feed.
	 *
	 * This page must be reached by a POST request.
	 *
	 * Parameters are:
	 *   - f_id (default: false)
	 *   - c_id (default: false)
	 * If c_id is false, default category is used.
	 *
	 * @todo should handle order of the feed inside the category.
	 */
	public function moveAction(): void {
		if (!Minz_Request::isPost()) {
			Minz_Request::forward(['c' => 'subscription'], true);
		}

		$feed_id = Minz_Request::paramInt('f_id');
		$cat_id = Minz_Request::paramInt('c_id');

		if (self::moveFeed($feed_id, $cat_id)) {
			// TODO: return something useful
			// Log a notice to prevent "Empty IF statement" warning in PHP_CodeSniffer
			Minz_Log::notice('Moved feed `' . $feed_id . '` in the category `' . $cat_id . '`');
		} else {
			Minz_Log::warning('Cannot move feed `' . $feed_id . '` in the category `' . $cat_id . '`');
			Minz_Error::error(404);
		}
	}

	public static function deleteFeed(int $feed_id): bool {
		FreshRSS_UserDAO::touch();
		$feedDAO = FreshRSS_Factory::createFeedDao();
		if ($feedDAO->deleteFeed($feed_id)) {
			// TODO: Delete old favicon

			// Remove related queries
			/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries */
			$queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries);
			FreshRSS_Context::userConf()->queries = $queries;
			FreshRSS_Context::userConf()->save();
			return true;
		}
		return false;
	}

	/**
	 * This action deletes a feed.
	 *
	 * This page must be reached by a POST request.
	 * If there are related queries, they are deleted too.
	 *
	 * Parameters are:
	 *   - id (default: false)
	 */
	public function deleteAction(): void {
		$from = Minz_Request::paramString('from');
		$id = Minz_Request::paramInt('id');

		switch ($from) {
			case 'stats':
				$redirect_url = ['c' => 'stats', 'a' => 'idle'];
				break;
			case 'normal':
				$get = Minz_Request::paramString('get');
				if ($get) {
					$redirect_url = ['c' => 'index', 'a' => 'normal', 'params' => ['get' => $get]];
				} else {
					$redirect_url = ['c' => 'index', 'a' => 'normal'];
				}
				break;
			default:
				$redirect_url = ['c' => 'subscription', 'a' => 'index'];
				if (!Minz_Request::isPost()) {
					Minz_Request::forward($redirect_url, true);
				}
		}

		if (self::deleteFeed($id)) {
			Minz_Request::good(_t('feedback.sub.feed.deleted'), $redirect_url);
		} else {
			Minz_Request::bad(_t('feedback.sub.feed.error'), $redirect_url);
		}
	}

	/**
	 * This action force clears the cache of a feed.
	 *
	 * Parameters are:
	 *   - id (mandatory - no default): Feed ID
	 *
	 */
	public function clearCacheAction(): void {
		//Get Feed.
		$id = Minz_Request::paramInt('id');

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$feed = $feedDAO->searchById($id);
		if ($feed === null) {
			Minz_Request::bad(_t('feedback.sub.feed.not_found'), []);
			return;
		}

		$feed->clearCache();

		Minz_Request::good(_t('feedback.sub.feed.cache_cleared', $feed->name()), [
			'params' => ['get' => 'f_' . $feed->id()],
		]);
	}

	/**
	 * This action forces reloading the articles of a feed.
	 *
	 * Parameters are:
	 *   - id (mandatory - no default): Feed ID
	 *
	 * @throws FreshRSS_BadUrl_Exception
	 */
	public function reloadAction(): void {
		if (function_exists('set_time_limit')) {
			@set_time_limit(300);
		}

		//Get Feed ID.
		$feed_id = Minz_Request::paramInt('id');
		$limit = Minz_Request::paramInt('reload_limit') ?: 10;

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$feed = $feedDAO->searchById($feed_id);
		if ($feed === null) {
			Minz_Request::bad(_t('feedback.sub.feed.not_found'), []);
			return;
		}

		//Re-fetch articles as if the feed was new.
		$feedDAO->updateFeed($feed->id(), [ 'lastUpdate' => 0 ]);
		self::actualizeFeedsAndCommit($feed_id);

		//Extract all feed entries from database, load complete content and store them back in database.
		$entryDAO = FreshRSS_Factory::createEntryDao();
		$entries = $entryDAO->listWhere('f', $feed_id, FreshRSS_Entry::STATE_ALL, 'DESC', $limit);

		//We need another DB connection in parallel for unbuffered streaming
		Minz_ModelPdo::$usesSharedPdo = false;
		if (FreshRSS_Context::systemConf()->db['type'] === 'mysql') {
			// Second parallel connection for unbuffered streaming: MySQL
			$entryDAO2 = FreshRSS_Factory::createEntryDao();
		} else {
			// Single connection for buffered queries (in memory): SQLite, PostgreSQL
			//TODO: Consider an unbuffered query for PostgreSQL
			$entryDAO2 = $entryDAO;
		}

		foreach ($entries as $entry) {
			if ($entry->loadCompleteContent(true)) {
				$entryDAO2->updateEntry($entry->toArray());
			}
		}

		Minz_ModelPdo::$usesSharedPdo = true;

		//Give feedback to user.
		Minz_Request::good(_t('feedback.sub.feed.reloaded', $feed->name()), [
			'params' => ['get' => 'f_' . $feed->id()]
		]);
	}

	/**
	 * This action creates a preview of a content-selector.
	 *
	 * Parameters are:
	 *   - id (mandatory - no default): Feed ID
	 *   - selector (mandatory - no default): Selector to preview
	 *
	 */
	public function contentSelectorPreviewAction(): void {

		//Configure.
		$this->view->fatalError = '';
		$this->view->selectorSuccess = false;
		$this->view->htmlContent = '';

		$this->view->_layout(null);

		$this->_csp([
			'default-src' => "'self'",
			'frame-src' => '*',
			'img-src' => '* data:',
			'media-src' => '*',
		]);

		//Get parameters.
		$feed_id = Minz_Request::paramInt('id');
		$content_selector = Minz_Request::paramString('selector');

		if ($content_selector === '') {
			$this->view->fatalError = _t('feedback.sub.feed.selector_preview.selector_empty');
			return;
		}

		//Check Feed ID validity.
		$entryDAO = FreshRSS_Factory::createEntryDao();
		$entries = $entryDAO->listWhere('f', $feed_id);
		$entry = null;

		//Get first entry (syntax robust for Generator or Array)
		foreach ($entries as $myEntry) {
			$entry = $myEntry;
		}

		if ($entry == null) {
			$this->view->fatalError = _t('feedback.sub.feed.selector_preview.no_entries');
			return;
		}

		//Get feed.
		$feed = $entry->feed();
		if ($feed === null) {
			$this->view->fatalError = _t('feedback.sub.feed.selector_preview.no_feed');
			return;
		}
		$feed->_pathEntries($content_selector);
		$feed->_attribute('path_entries_filter', Minz_Request::paramString('selector_filter', true));

		//Fetch & select content.
		try {
			$fullContent = $entry->getContentByParsing();

			if ($fullContent != '') {
				$this->view->selectorSuccess = true;
				$this->view->htmlContent = $fullContent;
			} else {
				$this->view->selectorSuccess = false;
				$this->view->htmlContent = $entry->content(false);
			}
		} catch (Exception $e) {
			$this->view->fatalError = _t('feedback.sub.feed.selector_preview.http_error');
		}
	}
}
importExportController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/importExportController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle every import and export actions.
 */
class FreshRSS_importExport_Controller extends FreshRSS_ActionController {

	private FreshRSS_EntryDAO $entryDAO;

	private FreshRSS_FeedDAO $feedDAO;

	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$this->entryDAO = FreshRSS_Factory::createEntryDao();
		$this->feedDAO = FreshRSS_Factory::createFeedDao();
	}

	/**
	 * This action displays the main page for import / export system.
	 */
	public function indexAction(): void {
		$this->view->feeds = $this->feedDAO->listFeeds();
		FreshRSS_View::prependTitle(_t('sub.import_export.title') . ' · ');
	}

	/**
	 * @return float|int|string
	 */
	private static function megabytes(string $size_str) {
		switch (substr($size_str, -1)) {
			case 'M':
			case 'm':
				return (int)$size_str;
			case 'K':
			case 'k':
				return (int)$size_str / 1024;
			case 'G':
			case 'g':
				return (int)$size_str * 1024;
		}
		return $size_str;
	}

	/**
	 * @param string|int $mb
	 */
	private static function minimumMemory($mb): void {
		$mb = (int)$mb;
		$ini = self::megabytes(ini_get('memory_limit') ?: '0');
		if ($ini < $mb) {
			ini_set('memory_limit', $mb . 'M');
		}
	}

	/**
	 * @throws FreshRSS_Zip_Exception
	 * @throws FreshRSS_ZipMissing_Exception
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function importFile(string $name, string $path, ?string $username = null): bool {
		self::minimumMemory(256);

		$this->entryDAO = FreshRSS_Factory::createEntryDao($username);
		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);

		$type_file = self::guessFileType($name);

		$list_files = [
			'opml' => [],
			'json_starred' => [],
			'json_feed' => [],
			'ttrss_starred' => [],
		];

		// We try to list all files according to their type
		$list = [];
		if ('zip' === $type_file && extension_loaded('zip')) {
			$zip = new ZipArchive();
			$result = $zip->open($path);
			if (true !== $result) {
				// zip_open cannot open file: something is wrong
				throw new FreshRSS_Zip_Exception($result);
			}
			for ($i = 0; $i < $zip->numFiles; $i++) {
				if ($zip->getNameIndex($i) === false) {
					continue;
				}
				$type_zipfile = self::guessFileType($zip->getNameIndex($i));
				if ('unknown' !== $type_zipfile) {
					$list_files[$type_zipfile][] = $zip->getFromIndex($i);
				}
			}
			$zip->close();
		} elseif ('zip' === $type_file) {
			// ZIP extension is not loaded
			throw new FreshRSS_ZipMissing_Exception();
		} elseif ('unknown' !== $type_file) {
			$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.
		$ok = true;

		$importService = new FreshRSS_Import_Service($username);

		foreach ($list_files['opml'] as $opml_file) {
			if ($opml_file === false) {
				continue;
			}
			$importService->importOpml($opml_file);
			if (!$importService->lastStatus()) {
				$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) {
			if (!is_string($article_file) || !$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) {
			if (!is_string($article_file) || !$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');
				}
			}
		}
		foreach ($list_files['ttrss_starred'] as $article_file) {
			$json = is_string($article_file) ? $this->ttrssXmlToJson($article_file) : false;
			if ($json === false || !$this->importJson($json, true)) {
				$ok = false;
				if (FreshRSS_Context::$isCli) {
					fwrite(STDERR, 'FreshRSS error during TT-RSS articles import' . "\n");
				} else {
					Minz_Log::warning('Error during TT-RSS articles 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(): void {
		if (!Minz_Request::isPost()) {
			Minz_Request::forward(['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'), [ 'c' => 'importExport', 'a' => 'index' ]);
		}

		if (function_exists('set_time_limit')) {
			@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'),
				['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'),
				['c' => 'importExport', 'a' => 'index']
			);
		}

		// And finally, we get import status and redirect to the home page
		$content_notif = $error === true ? _t('feedback.import_export.feeds_imported_with_errors') : _t('feedback.import_export.feeds_imported');
		Minz_Request::good($content_notif);
	}

	/**
	 * This method tries to guess the file type based on its name.
	 *
	 * It is a *very* basic guess file type function. Only based on filename.
	 * That could be improved but should be enough for what we have to do.
	 */
	private static function guessFileType(string $filename): string {
		if (substr_compare($filename, '.zip', -4) === 0) {
			return 'zip';
		} elseif (stripos($filename, 'opml') !== false) {
			return 'opml';
		} elseif (substr_compare($filename, '.json', -5) === 0) {
			if (strpos($filename, 'starred') !== false) {
				return 'json_starred';
			} else {
				return 'json_feed';
			}
		} elseif (substr_compare($filename, '.xml', -4) === 0) {
			if (preg_match('/Tiny|tt-?rss/i', $filename)) {
				return 'ttrss_starred';
			} else {
				return 'opml';
			}
		}
		return 'unknown';
	}

	/**
	 * @return false|string
	 */
	private function ttrssXmlToJson(string $xml) {
		$table = (array)simplexml_load_string($xml, null, LIBXML_NOBLANKS | LIBXML_NOCDATA);
		$table['items'] = $table['article'] ?? [];
		unset($table['article']);
		for ($i = count($table['items']) - 1; $i >= 0; $i--) {
			$item = (array)($table['items'][$i]);
			$item = array_filter($item, static fn($v) =>
				// Filter out empty properties, potentially reported as empty objects
				(is_string($v) && trim($v) !== '') || !empty($v));
			$item['updated'] = isset($item['updated']) ? strtotime($item['updated']) : '';
			$item['published'] = $item['updated'];
			$item['content'] = ['content' => $item['content'] ?? ''];
			$item['categories'] = isset($item['tag_cache']) ? [$item['tag_cache']] : [];
			if (!empty($item['marked'])) {
				$item['categories'][] = 'user/-/state/com.google/starred';
			}
			if (!empty($item['published'])) {
				$item['categories'][] = 'user/-/state/com.google/broadcast';
			}
			if (!empty($item['label_cache'])) {
				$labels_cache = json_decode($item['label_cache'], true);
				if (is_array($labels_cache)) {
					foreach ($labels_cache as $label_cache) {
						if (!empty($label_cache[1])) {
							$item['categories'][] = 'user/-/label/' . trim($label_cache[1]);
						}
					}
				}
			}
			$item['alternate'][0]['href'] = $item['link'] ?? '';
			$item['origin'] = [
				'title' => $item['feed_title'] ?? '',
				'feedUrl' => $item['feed_url'] ?? '',
			];
			$item['id'] = $item['guid'] ?? ($item['feed_url'] ?? $item['published']);
			$item['guid'] = $item['id'];
			$table['items'][$i] = $item;
		}
		return json_encode($table);
	}

	/**
	 * This method import a JSON-based file (Google Reader format).
	 *
	 * $article_file the JSON file content.
	 * true if articles from the file must be starred.
	 * @return bool false if an error occurred, true otherwise.
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	private function importJson(string $article_file, bool $starred = false): bool {
		$article_object = json_decode($article_file, true);
		if (!is_array($article_object)) {
			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;
		}
		$items = $article_object['items'] ?? $article_object;

		$mark_as_read = FreshRSS_Context::userConf()->mark_when['reception'] ? 1 : 0;

		$error = false;
		$article_to_feed = [];

		$nb_feeds = count($this->feedDAO->listFeeds());
		$newFeedGuids = [];
		$limits = FreshRSS_Context::systemConf()->limits;

		// First, we check feeds of articles are in DB (and add them if needed).
		foreach ($items as &$item) {
			if (!isset($item['guid']) && isset($item['id'])) {
				$item['guid'] = $item['id'];
			}
			if (empty($item['guid'])) {
				continue;
			}
			if (empty($item['origin'])) {
				$item['origin'] = [];
			}
			if (empty($item['origin']['title']) || trim($item['origin']['title']) === '') {
				$item['origin']['title'] = 'Import';
			}
			if (!empty($item['origin']['feedUrl'])) {
				$feedUrl = $item['origin']['feedUrl'];
			} elseif (!empty($item['origin']['streamId']) && strpos($item['origin']['streamId'], 'feed/') === 0) {
				$feedUrl = substr($item['origin']['streamId'], 5);	//Google Reader
				$item['origin']['feedUrl'] = $feedUrl;
			} elseif (!empty($item['origin']['htmlUrl'])) {
				$feedUrl = $item['origin']['htmlUrl'];
			} else {
				$feedUrl = 'http://import.localhost/import.xml';
				$item['origin']['feedUrl'] = $feedUrl;
				$item['origin']['disable'] = true;
			}
			$feed = new FreshRSS_Feed($feedUrl);
			$feed = $this->feedDAO->searchByUrl($feed->url());

			if ($feed === null) {
				// Feed does not exist in DB,we should to try to add it.
				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']);
				}

				if ($feed === null) {
					// Still null? It means something went wrong.
					$error = true;
				} else {
					$nb_feeds++;
				}
			}

			if ($feed != null) {
				$article_to_feed[$item['guid']] = $feed->id();
				if (!isset($newFeedGuids['f_' . $feed->id()])) {
					$newFeedGuids['f_' . $feed->id()] = [];
				}
				$newFeedGuids['f_' . $feed->id()][] = safe_ascii($item['guid']);
			}
		}

		$tagDAO = FreshRSS_Factory::createTagDao();
		$labels = FreshRSS_Context::labels();
		$knownLabels = [];
		foreach ($labels as $label) {
			$knownLabels[$label->name()]['id'] = $label->id();
			$knownLabels[$label->name()]['articles'] = [];
		}
		unset($labels);

		// For each feed, check existing GUIDs already in database.
		$existingHashForGuids = [];
		foreach ($newFeedGuids as $feedId => $newGuids) {
			$existingHashForGuids[$feedId] = $this->entryDAO->listHashForFeedGuids((int)substr($feedId, 2), $newGuids);
		}
		unset($newFeedGuids);

		// Then, articles are imported.
		$newGuids = [];
		$this->entryDAO->beginTransaction();
		foreach ($items as &$item) {
			if (empty($item['guid']) || empty($article_to_feed[$item['guid']])) {
				// Related feed does not exist for this entry, do nothing.
				continue;
			}

			$feed_id = $article_to_feed[$item['guid']];
			$author = $item['author'] ?? '';
			$is_starred = null; // null is used to preserve the current state if that item exists and is already starred
			$is_read = null;
			$tags = empty($item['categories']) ? [] : $item['categories'];
			$labels = [];
			for ($i = count($tags) - 1; $i >= 0; $i--) {
				$tag = trim($tags[$i]);
				if (strpos($tag, 'user/-/') !== false) {
					if ($tag === 'user/-/state/com.google/starred') {
						$is_starred = true;
					} elseif ($tag === 'user/-/state/com.google/read') {
						$is_read = true;
					} elseif ($tag === 'user/-/state/com.google/unread') {
						$is_read = false;
					} elseif (strpos($tag, 'user/-/label/') === 0) {
						$tag = trim(substr($tag, 13));
						if ($tag != '') {
							$labels[] = $tag;
						}
					}
					unset($tags[$i]);
				}
			}
			if ($starred && !$is_starred) {
				//If the article has no label, mark it as starred (old format)
				$is_starred = empty($labels);
			}
			if ($is_read === null) {
				$is_read = $mark_as_read;
			}

			if (isset($item['alternate'][0]['href'])) {
				$url = $item['alternate'][0]['href'];
			} elseif (isset($item['url'])) {
				$url = $item['url'];	//FeedBin
			} else {
				$url = '';
			}
			if (!is_string($url)) {
				$url = '';
			}

			$title = empty($item['title']) ? $url : $item['title'];

			if (isset($item['content']['content']) && is_string($item['content']['content'])) {
				$content = $item['content']['content'];
			} elseif (isset($item['summary']['content']) && is_string($item['summary']['content'])) {
				$content = $item['summary']['content'];
			} elseif (isset($item['content']) && is_string($item['content'])) {
				$content = $item['content'];	//FeedBin
			} else {
				$content = '';
			}
			$content = sanitizeHTML($content, $url);

			if (!empty($item['published'])) {
				$published = '' . $item['published'];
			} elseif (!empty($item['timestampUsec'])) {
				$published = substr('' . $item['timestampUsec'], 0, -6);
			} elseif (!empty($item['updated'])) {
				$published = '' . $item['updated'];
			} else {
				$published = '0';
			}
			if (!ctype_digit($published)) {
				$published = '' . strtotime($published);
			}
			if (strlen($published) > 10) {	// Milliseconds, e.g. Feedly
				$published = substr($published, 0, -3);
			}

			$entry = new FreshRSS_Entry(
				$feed_id, $item['guid'], $title, $author,
				$content, $url, $published, $is_read, $is_starred
			);
			$entry->_id(uTimeString());
			$entry->_tags($tags);

			if (isset($newGuids[$entry->guid()])) {
				continue;	//Skip subsequent articles with same GUID
			}
			$newGuids[$entry->guid()] = true;

			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
			if (!($entry instanceof FreshRSS_Entry)) {
				// An extension has returned a null value, there is nothing to insert.
				continue;
			}

			if (isset($existingHashForGuids['f_' . $feed_id][$entry->guid()])) {
				$ok = $this->entryDAO->updateEntry($entry->toArray());
			} else {
				$entry->_lastSeen(time());
				$ok = $this->entryDAO->addEntry($entry->toArray());
			}

			foreach ($labels as $labelName) {
				if (empty($knownLabels[$labelName]['id'])) {
					$labelId = $tagDAO->addTag(['name' => $labelName]);
					$knownLabels[$labelName]['id'] = $labelId;
					$knownLabels[$labelName]['articles'] = [];
				}
				$knownLabels[$labelName]['articles'][] = [
					//'id' => $entry->id(),	//ID changes after commitNewEntries()
					'id_feed' => $entry->feedId(),
					'guid' => $entry->guid(),
				];
			}

			$error |= ($ok === false);
		}
		$this->entryDAO->commit();

		$this->entryDAO->beginTransaction();
		$this->entryDAO->commitNewEntries();
		$this->feedDAO->updateCachedValues();
		$this->entryDAO->commit();

		$this->entryDAO->beginTransaction();
		foreach ($knownLabels as $labelName => $knownLabel) {
			$labelId = $knownLabel['id'];
			if (!$labelId) {
				continue;
			}
			foreach ($knownLabel['articles'] as $article) {
				$entryId = $this->entryDAO->searchIdByGuid($article['id_feed'], $article['guid']);
				if ($entryId != null) {
					$tagDAO->tagEntry($labelId, $entryId);
				} else {
					Minz_Log::warning('Could not add label "' . $labelName . '" to entry "' . $article['guid'] . '" in feed ' . $article['id_feed']);
				}
			}
		}
		$this->entryDAO->commit();

		return !$error;
	}

	/**
	 * This method import a JSON-based feed (Google Reader format).
	 *
	 * @param array<string,string> $origin represents a feed.
	 * @return FreshRSS_Feed|null if feed is in database at the end of the process, else null.
	 */
	private function addFeedJson(array $origin): ?FreshRSS_Feed {
		$return = null;
		if (!empty($origin['feedUrl'])) {
			$url = $origin['feedUrl'];
		} elseif (!empty($origin['htmlUrl'])) {
			$url = $origin['htmlUrl'];
		} else {
			return null;
		}
		if (!empty($origin['htmlUrl'])) {
			$website = $origin['htmlUrl'];
		} elseif (!empty($origin['feedUrl'])) {
			$website = $origin['feedUrl'];
		} else {
			$website = '';
		}
		$name = empty($origin['title']) ? $website : $origin['title'];

		try {
			// Create a Feed object and add it in database.
			$feed = new FreshRSS_Feed($url);
			$feed->_categoryId(FreshRSS_CategoryDAO::DEFAULTCATEGORYID);
			$feed->_name($name);
			$feed->_website($website);
			if (!empty($origin['disable'])) {
				$feed->_mute(true);
			}

			// Call the extension hook
			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
			if ($feed instanceof FreshRSS_Feed) {
				// addFeedObject checks if feed is already in DB so nothing else to
				// check here.
				$id = $this->feedDAO->addFeedObject($feed);

				if ($id !== false) {
					$feed->_id($id);
					$return = $feed;
				}
			}
		} catch (FreshRSS_Feed_Exception $e) {
			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_labelled (default: false)
	 *   - export_feeds (default: array()) a list of feed ids
	 */
	public function exportAction(): void {
		if (!Minz_Request::isPost()) {
			Minz_Request::forward(['c' => 'importExport', 'a' => 'index'], true);
			return;
		}

		$username = Minz_User::name() ?? '_';
		$export_service = new FreshRSS_Export_Service($username);

		$export_opml = Minz_Request::paramBoolean('export_opml');
		$export_starred = Minz_Request::paramBoolean('export_starred');
		$export_labelled = Minz_Request::paramBoolean('export_labelled');
		/** @var array<numeric-string> */
		$export_feeds = Minz_Request::paramArray('export_feeds');
		$max_number_entries = 50;

		$exported_files = [];

		if ($export_opml) {
			[$filename, $content] = $export_service->generateOpml();
			$exported_files[$filename] = $content;
		}

		// Starred and labelled entries are merged in the same `starred` file
		// to avoid duplication of content.
		if ($export_starred && $export_labelled) {
			[$filename, $content] = $export_service->generateStarredEntries('ST');
			$exported_files[$filename] = $content;
		} elseif ($export_starred) {
			[$filename, $content] = $export_service->generateStarredEntries('S');
			$exported_files[$filename] = $content;
		} elseif ($export_labelled) {
			[$filename, $content] = $export_service->generateStarredEntries('T');
			$exported_files[$filename] = $content;
		}

		foreach ($export_feeds as $feed_id) {
			$result = $export_service->generateFeedEntries((int)$feed_id, $max_number_entries);
			if (!$result) {
				// It means the actual feed_id doesn’t correspond to any existing feed
				continue;
			}

			[$filename, $content] = $result;
			$exported_files[$filename] = $content;
		}

		$nb_files = count($exported_files);
		if ($nb_files <= 0) {
			// There’s nothing to do, there are no files to export
			Minz_Request::forward(['c' => 'importExport', 'a' => 'index'], true);
			return;
		}

		if ($nb_files === 1) {
			// If we only have one file, we just export it as it is
			$filename = key($exported_files);
			$content = $exported_files[$filename];
		} else {
			// More files? Let’s compress them in a Zip archive
			if (!extension_loaded('zip')) {
				// Oops, there is no ZIP extension!
				Minz_Request::bad(
					_t('feedback.import_export.export_no_zip_extension'),
					['c' => 'importExport', 'a' => 'index']
				);
				return;
			}

			[$filename, $content] = $export_service->zip($exported_files);
		}

		if (!is_string($content)) {
			Minz_Request::bad(_t('feedback.import_export.zip_error'), ['c' => 'importExport', 'a' => 'index']);
			return;
		}

		$content_type = self::filenameToContentType($filename);
		header('Content-Type: ' . $content_type);
		header('Content-disposition: attachment; filename="' . $filename . '"');

		$this->view->_layout(null);
		$this->view->content = $content;
	}

	/**
	 * Return the Content-Type corresponding to a filename.
	 *
	 * If the type of the filename is not supported, it returns
	 * `application/octet-stream` by default.
	 */
	private static function filenameToContentType(string $filename): string {
		$filetype = self::guessFileType($filename);
		switch ($filetype) {
			case 'zip':
				return 'application/zip';
			case 'opml':
				return 'application/xml; charset=utf-8';
			case 'json_starred':
			case 'json_feed':
				return 'application/json; charset=utf-8';
			default:
				return 'application/octet-stream';
		}
	}
}
indexController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/indexController.php'
View Content
<?php
declare(strict_types=1);

/**
 * This class handles main actions of FreshRSS.
 */
class FreshRSS_index_Controller extends FreshRSS_ActionController {

	#[\Override]
	public function firstAction(): void {
		$this->view->html_url = Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root');
	}

	/**
	 * This action only redirect on the default view mode (normal or global)
	 */
	public function indexAction(): void {
		$preferred_output = FreshRSS_Context::userConf()->view_mode;
		Minz_Request::forward([
			'c' => 'index',
			'a' => $preferred_output,
		]);
	}

	/**
	 * This action displays the normal view of FreshRSS.
	 */
	public function normalAction(): void {
		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
		if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
			Minz_Request::forward(['c' => 'auth', 'a' => 'login']);
			return;
		}

		$id = Minz_Request::paramInt('id');
		if ($id !== 0) {
			$view = Minz_Request::paramString('a');
			$url_redirect = ['c' => 'subscription', 'a' => 'feed', 'params' => ['id' => (string)$id, 'from' => $view]];
			Minz_Request::forward($url_redirect, true);
			return;
		}

		try {
			FreshRSS_Context::updateUsingRequest(true);
		} catch (FreshRSS_Context_Exception $e) {
			Minz_Error::error(404);
		}

		$this->_csp([
			'default-src' => "'self'",
			'frame-src' => '*',
			'img-src' => '* data:',
			'media-src' => '*',
		]);

		$this->view->categories = FreshRSS_Context::categories();

		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
		$title = FreshRSS_Context::$name;
		if (FreshRSS_Context::$get_unread > 0) {
			$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
		}
		FreshRSS_View::prependTitle($title . ' · ');

		FreshRSS_Context::$id_max = time() . '000000';

		$this->view->callbackBeforeFeeds = static function (FreshRSS_View $view) {
			$view->tags = FreshRSS_Context::labels(true);
			$view->nbUnreadTags = 0;
			foreach ($view->tags as $tag) {
				$view->nbUnreadTags += $tag->nbUnread();
			}
		};

		$this->view->callbackBeforeEntries = static function (FreshRSS_View $view) {
			try {
				// +1 to account for paging logic
				$view->entries = FreshRSS_index_Controller::listEntriesByContext(FreshRSS_Context::$number + 1);
				ob_start();	//Buffer "one entry at a time"
			} catch (FreshRSS_EntriesGetter_Exception $e) {
				Minz_Log::notice($e->getMessage());
				Minz_Error::error(404);
			}
		};

		$this->view->callbackBeforePagination = static function (?FreshRSS_View $view, int $nbEntries, FreshRSS_Entry $lastEntry) {
			if ($nbEntries >= FreshRSS_Context::$number) {
				//We have enough entries: we discard the last one to use it for the next articles' page
				ob_clean();
				FreshRSS_Context::$next_id = $lastEntry->id();
			}
			ob_end_flush();
		};
	}

	/**
	 * This action displays the reader view of FreshRSS.
	 *
	 * @todo: change this view into specific CSS rules?
	 */
	public function readerAction(): void {
		$this->normalAction();
	}

	/**
	 * This action displays the global view of FreshRSS.
	 */
	public function globalAction(): void {
		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
		if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
			Minz_Request::forward(['c' => 'auth', 'a' => 'login']);
			return;
		}

		FreshRSS_View::appendScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js')));
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));

		try {
			FreshRSS_Context::updateUsingRequest(true);
		} catch (FreshRSS_Context_Exception $e) {
			Minz_Error::error(404);
		}

		$this->view->categories = FreshRSS_Context::categories();

		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
		$title = _t('index.feed.title_global');
		if (FreshRSS_Context::$get_unread > 0) {
			$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
		}
		FreshRSS_View::prependTitle($title . ' · ');

		$this->_csp([
			'default-src' => "'self'",
			'frame-src' => '*',
			'img-src' => '* data:',
			'media-src' => '*',
		]);
	}

	/**
	 * This action displays the RSS feed of FreshRSS.
	 * @deprecated See user query RSS sharing instead
	 */
	public function rssAction(): void {
		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
		$token = FreshRSS_Context::userConf()->token;
		$token_param = Minz_Request::paramString('token');
		$token_is_ok = ($token != '' && $token === $token_param);

		// Check if user has access.
		if (!FreshRSS_Auth::hasAccess() &&
				!$allow_anonymous &&
				!$token_is_ok) {
			Minz_Error::error(403);
		}

		try {
			FreshRSS_Context::updateUsingRequest(false);
		} catch (FreshRSS_Context_Exception $e) {
			Minz_Error::error(404);
		}

		try {
			$this->view->entries = FreshRSS_index_Controller::listEntriesByContext();
		} catch (FreshRSS_EntriesGetter_Exception $e) {
			Minz_Log::notice($e->getMessage());
			Minz_Error::error(404);
		}

		$this->view->html_url = Minz_Url::display('', 'html', true);
		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
		$this->view->rss_url = htmlspecialchars(
			PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']), ENT_COMPAT, 'UTF-8');

		// No layout for RSS output.
		$this->view->_layout(null);
		header('Content-Type: application/rss+xml; charset=utf-8');
	}

	/**
	 * @deprecated See user query OPML sharing instead
	 */
	public function opmlAction(): void {
		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
		$token = FreshRSS_Context::userConf()->token;
		$token_param = Minz_Request::paramString('token');
		$token_is_ok = ($token != '' && $token === $token_param);

		// Check if user has access.
		if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous && !$token_is_ok) {
			Minz_Error::error(403);
		}

		try {
			FreshRSS_Context::updateUsingRequest(false);
		} catch (FreshRSS_Context_Exception $e) {
			Minz_Error::error(404);
		}

		$get = FreshRSS_Context::currentGet(true);
		$type = (string)$get[0];
		$id = (int)$get[1];

		$this->view->excludeMutedFeeds = $type !== 'f';	// Exclude muted feeds except when we focus on a feed

		switch ($type) {
			case 'a':
				$this->view->categories = FreshRSS_Context::categories();
				break;
			case 'c':
				$cat = FreshRSS_Context::categories()[$id] ?? null;
				if ($cat == null) {
					Minz_Error::error(404);
					return;
				}
				$this->view->categories = [ $cat->id() => $cat ];
				break;
			case 'f':
				// We most likely already have the feed object in cache
				$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
				if ($feed === null) {
					$feedDAO = FreshRSS_Factory::createFeedDao();
					$feed = $feedDAO->searchById($id);
					if ($feed == null) {
						Minz_Error::error(404);
						return;
					}
				}
				$this->view->feeds = [ $feed->id() => $feed ];
				break;
			case 's':
			case 't':
			case 'T':
			default:
				Minz_Error::error(404);
				return;
		}

		// No layout for OPML output.
		$this->view->_layout(null);
		header('Content-Type: application/xml; charset=utf-8');
	}

	/**
	 * This method returns a list of entries based on the Context object.
	 * @param int $postsPerPage override `FreshRSS_Context::$number`
	 * @return Traversable<FreshRSS_Entry>
	 * @throws FreshRSS_EntriesGetter_Exception
	 */
	public static function listEntriesByContext(?int $postsPerPage = null): Traversable {
		$entryDAO = FreshRSS_Factory::createEntryDao();

		$get = FreshRSS_Context::currentGet(true);
		if (is_array($get)) {
			$type = $get[0];
			$id = (int)($get[1]);
		} else {
			$type = $get;
			$id = 0;
		}

		$date_min = 0;
		if (FreshRSS_Context::$sinceHours > 0) {
			$date_min = time() - (FreshRSS_Context::$sinceHours * 3600);
		}

		foreach ($entryDAO->listWhere(
					$type, $id, FreshRSS_Context::$state, FreshRSS_Context::$order,
					$postsPerPage ?? FreshRSS_Context::$number, FreshRSS_Context::$offset, FreshRSS_Context::$first_id,
					FreshRSS_Context::$search, $date_min
				) as $entry) {
			yield $entry;
		}
	}

	/**
	 * This action displays the about page of FreshRSS.
	 */
	public function aboutAction(): void {
		FreshRSS_View::prependTitle(_t('index.about.title') . ' · ');
	}

	/**
	 * This action displays the EULA/TOS (Terms of Service) page of FreshRSS.
	 * This page is enabled only if admin created a data/tos.html file.
	 * The content of the page is the content of data/tos.html.
	 * It returns 404 if there is no EULA/TOS.
	 */
	public function tosAction(): void {
		$terms_of_service = file_get_contents(TOS_FILENAME);
		if ($terms_of_service === false) {
			Minz_Error::error(404);
			return;
		}

		$this->view->terms_of_service = $terms_of_service;
		$this->view->can_register = !max_registrations_reached();
		FreshRSS_View::prependTitle(_t('index.tos.title') . ' · ');
	}

	/**
	 * This action displays logs of FreshRSS for the current user.
	 */
	public function logsAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		FreshRSS_View::prependTitle(_t('index.log.title') . ' · ');

		if (Minz_Request::isPost()) {
			FreshRSS_LogDAO::truncate();
		}

		$logs = FreshRSS_LogDAO::lines();	//TODO: ask only the necessary lines

		//gestion pagination
		$page = Minz_Request::paramInt('page') ?: 1;
		$this->view->logsPaginator = new Minz_Paginator($logs);
		$this->view->logsPaginator->_nbItemsPerPage(50);
		$this->view->logsPaginator->_currentPage($page);
	}
}
javascriptController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/javascriptController.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_javascript_Controller extends FreshRSS_ActionController {

	/**
	 * @var FreshRSS_ViewJavascript
	 */
	protected $view;

	public function __construct() {
		parent::__construct(FreshRSS_ViewJavascript::class);
	}

	#[\Override]
	public function firstAction(): void {
		$this->view->_layout(null);
	}

	public function actualizeAction(): void {
		header('Content-Type: application/json; charset=UTF-8');
		Minz_Session::_param('actualize_feeds', false);

		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
		$databaseDAO->minorDbMaintenance();
		Minz_ExtensionManager::callHookVoid('freshrss_user_maintenance');

		$catDAO = FreshRSS_Factory::createCategoryDao();
		$this->view->categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::userConf()->dynamic_opml_ttl_default);

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::userConf()->ttl_default);
	}

	public function nbUnreadsPerFeedAction(): void {
		header('Content-Type: application/json; charset=UTF-8');
		$catDAO = FreshRSS_Factory::createCategoryDao();
		$this->view->categories = $catDAO->listCategories(true, false) ?: [];
		$tagDAO = FreshRSS_Factory::createTagDao();
		$this->view->tags = $tagDAO->listTags(true) ?: [];
	}

	//For Web-form login

	/**
	 * @throws Exception
	 */
	public function nonceAction(): void {
		header('Content-Type: application/json; charset=UTF-8');
		header('Last-Modified: ' . gmdate('D, d M Y H:i:s \G\M\T'));
		header('Expires: 0');
		header('Cache-Control: private, no-cache, no-store, must-revalidate');
		header('Pragma: no-cache');

		$user = $_GET['user'] ?? '';
		FreshRSS_Context::initUser($user);
		if (FreshRSS_Context::hasUserConf()) {
			try {
				$salt = FreshRSS_Context::systemConf()->salt;
				$s = FreshRSS_Context::userConf()->passwordHash;
				if (strlen($s) >= 60) {
					//CRYPT_BLOWFISH Salt: "$2a$", a two digit cost parameter, "$", and 22 characters from the alphabet "./0-9A-Za-z".
					$this->view->salt1 = substr($s, 0, 29);
					$this->view->nonce = sha1($salt . uniqid('' . mt_rand(), true));
					Minz_Session::_param('nonce', $this->view->nonce);
					return;	//Success
				}
			} catch (Minz_Exception $me) {
				Minz_Log::warning('Nonce failure: ' . $me->getMessage());
			}
		} else {
			Minz_Log::notice('Nonce failure due to invalid username! ' . $user);
		}
		//Failure: Return random data.
		$this->view->salt1 = sprintf('$2a$%02d$', FreshRSS_password_Util::BCRYPT_COST);
		$alphabet = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
		for ($i = 22; $i > 0; $i--) {
			$this->view->salt1 .= $alphabet[random_int(0, 63)];
		}
		$this->view->nonce = sha1('' . mt_rand());
	}
}
statsController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/statsController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle application statistics.
 */
class FreshRSS_stats_Controller extends FreshRSS_ActionController {

	/**
	 * @var FreshRSS_ViewStats
	 */
	protected $view;

	public function __construct() {
		parent::__construct(FreshRSS_ViewStats::class);
	}

	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$this->_csp([
			'default-src' => "'self'",
			'img-src' => '* data:',
			'style-src' => "'self' 'unsafe-inline'",
		]);

		$catDAO = FreshRSS_Factory::createCategoryDao();
		$catDAO->checkDefault();
		$this->view->categories = $catDAO->listSortedCategories(false) ?: [];

		FreshRSS_View::prependTitle(_t('admin.stats.title') . ' · ');
	}

	/**
	 * This action handles the statistic main page.
	 *
	 * It displays the statistic main page.
	 * The values computed to display the page are:
	 *   - repartition of read/unread/favorite/not favorite (repartition)
	 *   - number of article per day (entryCount)
	 *   - number of feed by category (feedByCategory)
	 *   - number of article by category (entryByCategory)
	 *   - list of most prolific feed (topFeed)
	 */
	public function indexAction(): void {
		$statsDAO = FreshRSS_Factory::createStatsDAO();
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/chart.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/chart.min.js')));

		$this->view->repartitions = $statsDAO->calculateEntryRepartition();

		$entryCount = $statsDAO->calculateEntryCount();
		if (count($entryCount) > 0) {
			$this->view->entryCount = $entryCount;
			$this->view->average = round(array_sum(array_values($entryCount)) / count($entryCount), 2);
		} else {
			$this->view->entryCount = [];
			$this->view->average = -1.0;
		}

		$feedByCategory = [];
		$feedByCategory_calculated = $statsDAO->calculateFeedByCategory();
		for ($i = 0; $i < count($feedByCategory_calculated); $i++) {
			$feedByCategory['label'][$i] = $feedByCategory_calculated[$i]['label'];
			$feedByCategory['data'][$i] = $feedByCategory_calculated[$i]['data'];
		}
		$this->view->feedByCategory = $feedByCategory;

		$entryByCategory = [];
		$entryByCategory_calculated = $statsDAO->calculateEntryByCategory();
		for ($i = 0; $i < count($entryByCategory_calculated); $i++) {
			$entryByCategory['label'][$i] = $entryByCategory_calculated[$i]['label'];
			$entryByCategory['data'][$i] = $entryByCategory_calculated[$i]['data'];
		}
		$this->view->entryByCategory = $entryByCategory;

		$this->view->topFeed = $statsDAO->calculateTopFeed();

		$last30DaysLabels = [];
		for ($i = 0; $i < 30; $i++) {
			$last30DaysLabels[$i] = date('d.m.Y', strtotime((-30 + $i) . ' days') ?: null);
		}

		$this->view->last30DaysLabels = $last30DaysLabels;
	}

	/**
	 * This action handles the feed action on the idle statistic page.
	 * set the 'from' parameter to remember that it had a redirection coming from stats controller,
	 * to use the subscription controller to save it,
	 * but shows the stats idle page
	 */
	public function feedAction(): void {
		$id = Minz_Request::paramInt('id');
		$ajax = Minz_Request::paramBoolean('ajax');
		if ($ajax) {
			$url_redirect = ['c' => 'subscription', 'a' => 'feed', 'params' => ['id' => (string)$id, 'from' => 'stats', 'ajax' => (string)$ajax]];
		} else {
			$url_redirect = ['c' => 'subscription', 'a' => 'feed', 'params' => ['id' => (string)$id, 'from' => 'stats']];
		}
		Minz_Request::forward($url_redirect, true);
	}

	/**
	 * This action handles the idle feed statistic page.
	 *
	 * It displays the list of idle feed for different period. The supported
	 * periods are:
	 *   - last 5 years
	 *   - last 3 years
	 *   - last 2 years
	 *   - last year
	 *   - last 6 months
	 *   - last 3 months
	 *   - last month
	 *   - last week
	 */
	public function idleAction(): void {
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
		$feed_dao = FreshRSS_Factory::createFeedDao();
		$statsDAO = FreshRSS_Factory::createStatsDAO();
		$feeds = $statsDAO->calculateFeedLastDate() ?: [];
		$idleFeeds = [
			'last_5_year' => [],
			'last_3_year' => [],
			'last_2_year' => [],
			'last_year' => [],
			'last_6_month' => [],
			'last_3_month' => [],
			'last_month' => [],
			'last_week' => [],
		];
		$now = new \DateTime();
		$feedDate = clone $now;
		$lastWeek = clone $now;
		$lastWeek->modify('-1 week');
		$lastMonth = clone $now;
		$lastMonth->modify('-1 month');
		$last3Month = clone $now;
		$last3Month->modify('-3 month');
		$last6Month = clone $now;
		$last6Month->modify('-6 month');
		$lastYear = clone $now;
		$lastYear->modify('-1 year');
		$last2Year = clone $now;
		$last2Year->modify('-2 year');
		$last3Year = clone $now;
		$last3Year->modify('-3 year');
		$last5Year = clone $now;
		$last5Year->modify('-5 year');

		foreach ($feeds as $feed) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$feedObject = $feedDAO->searchById($feed['id']);
			if ($feedObject !== null) {
				$feed['favicon'] = $feedObject->favicon();
			}

			$feedDate->setTimestamp($feed['last_date']);
			if ($feedDate >= $lastWeek) {
				continue;
			}
			if ($feedDate < $last5Year) {
				$idleFeeds['last_5_year'][] = $feed;
			} elseif ($feedDate < $last3Year) {
				$idleFeeds['last_3_year'][] = $feed;
			} elseif ($feedDate < $last2Year) {
				$idleFeeds['last_2_year'][] = $feed;
			} elseif ($feedDate < $lastYear) {
				$idleFeeds['last_year'][] = $feed;
			} elseif ($feedDate < $last6Month) {
				$idleFeeds['last_6_month'][] = $feed;
			} elseif ($feedDate < $last3Month) {
				$idleFeeds['last_3_month'][] = $feed;
			} elseif ($feedDate < $lastMonth) {
				$idleFeeds['last_month'][] = $feed;
			} elseif ($feedDate < $lastWeek) {
				$idleFeeds['last_week'][] = $feed;
			}
		}

		$this->view->idleFeeds = $idleFeeds;
		$this->view->feeds = $feed_dao->listFeeds();

		$id = Minz_Request::paramInt('id');
		$this->view->displaySlider = false;
		if ($id !== 0) {
			$this->view->displaySlider = true;
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->view->feed = $feedDAO->searchById($id) ?? FreshRSS_Feed::default();
		}
	}

	/**
	 * This action handles the article repartition statistic page.
	 *
	 * It displays the number of article and the average of article for the
	 * following periods:
	 *   - hour of the day
	 *   - day of the week
	 *   - month
	 *
	 * @todo verify that the metrics used here make some sense. Especially
	 *       for the average.
	 */
	public function repartitionAction(): void {
		$statsDAO 		= FreshRSS_Factory::createStatsDAO();
		$categoryDAO 	= FreshRSS_Factory::createCategoryDao();
		$feedDAO 		= FreshRSS_Factory::createFeedDao();

		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/chart.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/chart.min.js')));

		$id = Minz_Request::paramInt('id');
		if ($id === 0) {
			$id = null;
		}

		$this->view->categories 	= $categoryDAO->listCategories(true) ?: [];
		$this->view->feed 			= $id === null ? FreshRSS_Feed::default() : ($feedDAO->searchById($id) ?? FreshRSS_Feed::default());
		$this->view->days 			= $statsDAO->getDays();
		$this->view->months 		= $statsDAO->getMonths();

		$this->view->repartition 			= $statsDAO->calculateEntryRepartitionPerFeed($id);

		$this->view->repartitionHour 		= $statsDAO->calculateEntryRepartitionPerFeedPerHour($id);
		$this->view->averageHour 			= $statsDAO->calculateEntryAveragePerFeedPerHour($id);

		$this->view->repartitionDayOfWeek 	= $statsDAO->calculateEntryRepartitionPerFeedPerDayOfWeek($id);
		$this->view->averageDayOfWeek 		= $statsDAO->calculateEntryAveragePerFeedPerDayOfWeek($id);

		$this->view->repartitionMonth 		= $statsDAO->calculateEntryRepartitionPerFeedPerMonth($id);
		$this->view->averageMonth 			= $statsDAO->calculateEntryAveragePerFeedPerMonth($id);

		$hours24Labels = [];
		for ($i = 0; $i < 24; $i++) {
			$hours24Labels[$i] = $i . ':xx';
		}

		$this->view->hours24Labels = $hours24Labels;
	}
}
subscriptionController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/subscriptionController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle subscription actions.
 */
class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$catDAO = FreshRSS_Factory::createCategoryDao();
		$catDAO->checkDefault();
		$this->view->categories = $catDAO->listSortedCategories(false, true) ?: [];

		$signalError = false;
		foreach ($this->view->categories as $cat) {
			$feeds = $cat->feeds();
			foreach ($feeds as $feed) {
				if ($feed->inError()) {
					$signalError = true;
				}
			}
			if ($signalError) {
				break;
			}
		}

		$this->view->signalError = $signalError;
	}

	/**
	 * This action handles the main subscription page
	 *
	 * It displays categories and associated feeds.
	 */
	public function indexAction(): void {
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/category.js?' . @filemtime(PUBLIC_PATH . '/scripts/category.js')));
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
		FreshRSS_View::prependTitle(_t('sub.title') . ' · ');

		$this->view->onlyFeedsWithError = Minz_Request::paramBoolean('error');

		$id = Minz_Request::paramInt('id');
		$this->view->displaySlider = false;
		if ($id !== 0) {
			$type = Minz_Request::paramString('type');
			$this->view->displaySlider = true;
			switch ($type) {
				case 'category':
					$categoryDAO = FreshRSS_Factory::createCategoryDao();
					$this->view->category = $categoryDAO->searchById($id);
					break;
				default:
					$feedDAO = FreshRSS_Factory::createFeedDao();
					$this->view->feed = $feedDAO->searchById($id) ?? FreshRSS_Feed::default();
					break;
			}
		}
	}

	/**
	 * This action handles the feed configuration page.
	 *
	 * It displays the feed configuration page.
	 * If this action is reached through a POST request, it stores all new
	 * configuration values then sends a notification to the user.
	 *
	 * The options available on the page are:
	 *   - name
	 *   - description
	 *   - website URL
	 *   - feed URL
	 *   - category id (default: default category id)
	 *   - CSS path to article on website
	 *   - display in main stream (default: 0)
	 *   - HTTP authentication
	 *   - number of article to retain (default: -2)
	 *   - refresh frequency (default: 0)
	 * Default values are empty strings unless specified.
	 */
	public function feedAction(): void {
		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		} else {
			FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
		}

		$feedDAO = FreshRSS_Factory::createFeedDao();
		$this->view->feeds = $feedDAO->listFeeds();

		$id = Minz_Request::paramInt('id');
		if ($id === 0 || !isset($this->view->feeds[$id])) {
			Minz_Error::error(404);
			return;
		}

		$feed = $this->view->feeds[$id];
		$this->view->feed = $feed;

		FreshRSS_View::prependTitle($feed->name() . ' · ' . _t('sub.title.feed_management') . ' · ');

		if (Minz_Request::isPost()) {
			$user = Minz_Request::paramString('http_user_feed' . $id);
			$pass = Minz_Request::paramString('http_pass_feed' . $id);

			$httpAuth = '';
			if ($user !== '' && $pass !== '') {	//TODO: Sanitize
				$httpAuth = $user . ':' . $pass;
			}

			$feed->_ttl(Minz_Request::paramInt('ttl') ?: FreshRSS_Feed::TTL_DEFAULT);
			$feed->_mute(Minz_Request::paramBoolean('mute'));

			$feed->_attribute('read_upon_gone', Minz_Request::paramTernary('read_upon_gone'));
			$feed->_attribute('mark_updated_article_unread', Minz_Request::paramTernary('mark_updated_article_unread'));
			$feed->_attribute('read_upon_reception', Minz_Request::paramTernary('read_upon_reception'));
			$feed->_attribute('clear_cache', Minz_Request::paramTernary('clear_cache'));

			$keep_max_n_unread = Minz_Request::paramTernary('keep_max_n_unread') === true ? Minz_Request::paramInt('keep_max_n_unread') : null;
			$feed->_attribute('keep_max_n_unread', $keep_max_n_unread >= 0 ? $keep_max_n_unread : null);

			$read_when_same_title_in_feed = Minz_Request::paramString('read_when_same_title_in_feed');
			if ($read_when_same_title_in_feed === '') {
				$read_when_same_title_in_feed = null;
			} else {
				$read_when_same_title_in_feed = (int)$read_when_same_title_in_feed;
				if ($read_when_same_title_in_feed <= 0) {
					$read_when_same_title_in_feed = false;
				}
			}
			$feed->_attribute('read_when_same_title_in_feed', $read_when_same_title_in_feed);

			$cookie = Minz_Request::paramString('curl_params_cookie');
			$cookie_file = Minz_Request::paramBoolean('curl_params_cookiefile');
			$max_redirs = Minz_Request::paramInt('curl_params_redirects');
			$useragent = Minz_Request::paramString('curl_params_useragent');
			$proxy_address = Minz_Request::paramString('curl_params');
			$proxy_type = Minz_Request::paramString('proxy_type');
			$request_method = Minz_Request::paramString('curl_method');
			$request_fields = Minz_Request::paramString('curl_fields', true);
			$opts = [];
			if ($proxy_type !== '') {
				$opts[CURLOPT_PROXY] = $proxy_address;
				$opts[CURLOPT_PROXYTYPE] = (int)$proxy_type;
			}
			if ($cookie !== '') {
				$opts[CURLOPT_COOKIE] = $cookie;
			}
			if ($cookie_file) {
				// Pass empty cookie file name to enable the libcurl cookie engine
				// without reading any existing cookie data.
				$opts[CURLOPT_COOKIEFILE] = '';
			}
			if ($max_redirs != 0) {
				$opts[CURLOPT_MAXREDIRS] = $max_redirs;
				$opts[CURLOPT_FOLLOWLOCATION] = 1;
			}
			if ($useragent !== '') {
				$opts[CURLOPT_USERAGENT] = $useragent;
			}

			if ($request_method === 'POST') {
				$opts[CURLOPT_POST] = true;
				if ($request_fields !== '') {
					$opts[CURLOPT_POSTFIELDS] = $request_fields;
					if (json_decode($request_fields, true) !== null) {
						$opts[CURLOPT_HTTPHEADER] = ['Content-Type: application/json'];
					}
				}
			}

			$feed->_attribute('curl_params', empty($opts) ? null : $opts);

			$feed->_attribute('content_action', Minz_Request::paramString('content_action', true) ?: 'replace');

			$feed->_attribute('ssl_verify', Minz_Request::paramTernary('ssl_verify'));
			$timeout = Minz_Request::paramInt('timeout');
			$feed->_attribute('timeout', $timeout > 0 ? $timeout : null);

			if (Minz_Request::paramBoolean('use_default_purge_options')) {
				$feed->_attribute('archiving', null);
			} else {
				if (Minz_Request::paramBoolean('enable_keep_max')) {
					$keepMax = Minz_Request::paramInt('keep_max') ?: FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
				} else {
					$keepMax = false;
				}
				if (Minz_Request::paramBoolean('enable_keep_period')) {
					$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
					if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
						$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
					}
				} else {
					$keepPeriod = false;
				}
				$feed->_attribute('archiving', [
					'keep_period' => $keepPeriod,
					'keep_max' => $keepMax,
					'keep_min' => Minz_Request::paramInt('keep_min'),
					'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
					'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
					'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
				]);
			}

			$feed->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));

			$feed->_kind(Minz_Request::paramInt('feed_kind') ?: FreshRSS_Feed::KIND_RSS);
			if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
				$xPathSettings = [];
				if (Minz_Request::paramString('xPathItem') != '')
					$xPathSettings['item'] = Minz_Request::paramString('xPathItem', true);
				if (Minz_Request::paramString('xPathItemTitle') != '')
					$xPathSettings['itemTitle'] = Minz_Request::paramString('xPathItemTitle', true);
				if (Minz_Request::paramString('xPathItemContent') != '')
					$xPathSettings['itemContent'] = Minz_Request::paramString('xPathItemContent', true);
				if (Minz_Request::paramString('xPathItemUri') != '')
					$xPathSettings['itemUri'] = Minz_Request::paramString('xPathItemUri', true);
				if (Minz_Request::paramString('xPathItemAuthor') != '')
					$xPathSettings['itemAuthor'] = Minz_Request::paramString('xPathItemAuthor', true);
				if (Minz_Request::paramString('xPathItemTimestamp') != '')
					$xPathSettings['itemTimestamp'] = Minz_Request::paramString('xPathItemTimestamp', true);
				if (Minz_Request::paramString('xPathItemTimeFormat') != '')
					$xPathSettings['itemTimeFormat'] = Minz_Request::paramString('xPathItemTimeFormat', true);
				if (Minz_Request::paramString('xPathItemThumbnail') != '')
					$xPathSettings['itemThumbnail'] = Minz_Request::paramString('xPathItemThumbnail', true);
				if (Minz_Request::paramString('xPathItemCategories') != '')
					$xPathSettings['itemCategories'] = Minz_Request::paramString('xPathItemCategories', true);
				if (Minz_Request::paramString('xPathItemUid') != '')
					$xPathSettings['itemUid'] = Minz_Request::paramString('xPathItemUid', true);
				if (!empty($xPathSettings))
					$feed->_attribute('xpath', $xPathSettings);
			} elseif ($feed->kind() === FreshRSS_Feed::KIND_JSON_DOTNOTATION) {
				$jsonSettings = [];
				if (Minz_Request::paramString('jsonFeedTitle') !== '') {
					$jsonSettings['feedTitle'] = Minz_Request::paramString('jsonFeedTitle', true);
				}
				if (Minz_Request::paramString('jsonItem') !== '') {
					$jsonSettings['item'] = Minz_Request::paramString('jsonItem', true);
				}
				if (Minz_Request::paramString('jsonItemTitle') !== '') {
					$jsonSettings['itemTitle'] = Minz_Request::paramString('jsonItemTitle', true);
				}
				if (Minz_Request::paramString('jsonItemContent') !== '') {
					$jsonSettings['itemContent'] = Minz_Request::paramString('jsonItemContent', true);
				}
				if (Minz_Request::paramString('jsonItemUri') !== '') {
					$jsonSettings['itemUri'] = Minz_Request::paramString('jsonItemUri', true);
				}
				if (Minz_Request::paramString('jsonItemAuthor') !== '') {
					$jsonSettings['itemAuthor'] = Minz_Request::paramString('jsonItemAuthor', true);
				}
				if (Minz_Request::paramString('jsonItemTimestamp') !== '') {
					$jsonSettings['itemTimestamp'] = Minz_Request::paramString('jsonItemTimestamp', true);
				}
				if (Minz_Request::paramString('jsonItemTimeFormat') !== '') {
					$jsonSettings['itemTimeFormat'] = Minz_Request::paramString('jsonItemTimeFormat', true);
				}
				if (Minz_Request::paramString('jsonItemThumbnail') !== '') {
					$jsonSettings['itemThumbnail'] = Minz_Request::paramString('jsonItemThumbnail', true);
				}
				if (Minz_Request::paramString('jsonItemCategories') !== '') {
					$jsonSettings['itemCategories'] = Minz_Request::paramString('jsonItemCategories', true);
				}
				if (Minz_Request::paramString('jsonItemUid') !== '') {
					$jsonSettings['itemUid'] = Minz_Request::paramString('jsonItemUid', true);
				}
				if (!empty($jsonSettings)) {
					$feed->_attribute('json_dotnotation', $jsonSettings);
				}
			}

			$feed->_attribute('path_entries_filter', Minz_Request::paramString('path_entries_filter', true));

			$values = [
				'name' => Minz_Request::paramString('name'),
				'kind' => $feed->kind(),
				'description' => sanitizeHTML(Minz_Request::paramString('description', true)),
				'website' => checkUrl(Minz_Request::paramString('website')) ?: '',
				'url' => checkUrl(Minz_Request::paramString('url')) ?: '',
				'category' => Minz_Request::paramInt('category'),
				'pathEntries' => Minz_Request::paramString('path_entries'),
				'priority' => Minz_Request::paramTernary('priority') === null ? FreshRSS_Feed::PRIORITY_MAIN_STREAM : Minz_Request::paramInt('priority'),
				'httpAuth' => $httpAuth,
				'ttl' => $feed->ttl(true),
				'attributes' => $feed->attributes(),
			];

			invalidateHttpCache();

			$from = Minz_Request::paramString('from');
			switch ($from) {
				case 'stats':
					$url_redirect = ['c' => 'stats', 'a' => 'idle', 'params' => ['id' => $id, 'from' => 'stats']];
					break;
				case 'normal':
				case 'reader':
					$get = Minz_Request::paramString('get');
					if ($get) {
						$url_redirect = ['c' => 'index', 'a' => $from, 'params' => ['get' => $get]];
					} else {
						$url_redirect = ['c' => 'index', 'a' => $from];
					}
					break;
				default:
					$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id]];
			}

			if ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) {
				$feed->_categoryId($values['category']);
				// update url and website values for faviconPrepare
				$feed->_url($values['url'], false);
				$feed->_website($values['website'], false);
				$feed->faviconPrepare();

				Minz_Request::good(_t('feedback.sub.feed.updated'), $url_redirect);
			} else {
				if ($values['url'] == '') {
					Minz_Log::warning('Invalid feed URL!');
				}
				Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
			}
		}
	}

	/**
	 * This action displays the bookmarklet page.
	 */
	public function bookmarkletAction(): void {
		FreshRSS_View::prependTitle(_t('sub.title.subscription_tools') . ' . ');
	}

	/**
	 * This action displays the page to add a new feed
	 */
	public function addAction(): void {
		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
		FreshRSS_View::prependTitle(_t('sub.title.add') . ' . ');
	}
}
tagController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/tagController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle every tag actions.
 */
class FreshRSS_tag_Controller extends FreshRSS_ActionController {

	/**
	 * JavaScript request or not.
	 */
	private bool $ajax = false;

	/**
	 * This action is called before every other action in that class. It is
	 * the common boilerplate for every action. It is triggered by the
	 * underlying framework.
	 */
	#[\Override]
	public function firstAction(): void {
		// If ajax request, we do not print layout
		$this->ajax = Minz_Request::paramBoolean('ajax');
		if ($this->ajax) {
			$this->view->_layout(null);
		}
	}

	/**
	 * This action adds (checked=true) or removes (checked=false) a tag to an entry.
	 */
	public function tagEntryAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
		if (Minz_Request::isPost()) {
			$id_tag = Minz_Request::paramInt('id_tag');
			$name_tag = Minz_Request::paramString('name_tag');
			$id_entry = Minz_Request::paramString('id_entry');
			$checked = Minz_Request::paramBoolean('checked');
			if ($id_entry != '') {
				$tagDAO = FreshRSS_Factory::createTagDao();
				if ($id_tag == 0 && $name_tag !== '' && $checked) {
					if ($existing_tag = $tagDAO->searchByName($name_tag)) {
						// Use existing tag
						$tagDAO->tagEntry($existing_tag->id(), $id_entry, $checked);
					} else {
						//Create new tag
						$id_tag = $tagDAO->addTag(['name' => $name_tag]);
					}
				}
				if ($id_tag != false) {
					$tagDAO->tagEntry($id_tag, $id_entry, $checked);
				}
			}
		} else {
			Minz_Error::error(405);
		}
		if (!$this->ajax) {
			Minz_Request::forward([
				'c' => 'index',
				'a' => 'index',
			], true);
		}
	}

	public function deleteAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
		if (Minz_Request::isPost()) {
			$id_tag = Minz_Request::paramInt('id_tag');
			if ($id_tag !== 0) {
				$tagDAO = FreshRSS_Factory::createTagDao();
				$tagDAO->deleteTag($id_tag);
			}
		} else {
			Minz_Error::error(405);
		}
		if (!$this->ajax) {
			Minz_Request::forward([
				'c' => 'tag',
				'a' => 'index',
			], true);
		}
	}


	/**
	 * This action updates the given tag.
	 */
	public function updateAction(): void {
		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		}

		$tagDAO = FreshRSS_Factory::createTagDao();

		$id = Minz_Request::paramInt('id');
		$tag = $tagDAO->searchById($id);
		if ($id === 0 || $tag === null) {
			Minz_Error::error(404);
			return;
		}
		$this->view->tag = $tag;

		FreshRSS_View::prependTitle($tag->name() . ' · ' . _t('sub.title') . ' · ');

		if (Minz_Request::isPost()) {
			invalidateHttpCache();
			$ok = true;

			if ($tag->name() !== Minz_Request::paramString('name')) {
				$ok = $tagDAO->updateTagName($tag->id(), Minz_Request::paramString('name')) !== false;
			}

			if ($ok) {
				$tag->_filtersAction('label', Minz_Request::paramTextToArray('filteractions_label'));
				$ok = $tagDAO->updateTagAttributes($tag->id(), $tag->attributes()) !== false;
			}

			invalidateHttpCache();

			$url_redirect = ['c' => 'tag', 'a' => 'update', 'params' => ['id' => $id]];
			if ($ok) {
				Minz_Request::good(_t('feedback.tag.updated'), $url_redirect);
			} else {
				Minz_Request::bad(_t('feedback.tag.error'), $url_redirect);
			}
		}
	}

	public function getTagsForEntryAction(): void {
		if (!FreshRSS_Auth::hasAccess() && !FreshRSS_Context::systemConf()->allow_anonymous) {
			Minz_Error::error(403);
		}
		$this->view->_layout(null);
		header('Content-Type: application/json; charset=UTF-8');
		header('Cache-Control: private, no-cache, no-store, must-revalidate');
		$id_entry = Minz_Request::paramString('id_entry');
		$tagDAO = FreshRSS_Factory::createTagDao();
		$this->view->tagsForEntry = $tagDAO->getTagsForEntry($id_entry) ?: [];
	}

	public function addAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
		if (!Minz_Request::isPost()) {
			Minz_Error::error(405);
		}

		$name = Minz_Request::paramString('name');
		$tagDAO = FreshRSS_Factory::createTagDao();
		if (strlen($name) > 0 && null === $tagDAO->searchByName($name)) {
			$tagDAO->addTag(['name' => $name]);
			Minz_Request::good(_t('feedback.tag.created', $name), ['c' => 'tag', 'a' => 'index']);
		}

		Minz_Request::bad(_t('feedback.tag.name_exists', $name), ['c' => 'tag', 'a' => 'index']);
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function renameAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
		if (!Minz_Request::isPost()) {
			Minz_Error::error(405);
		}

		$targetName = Minz_Request::paramString('name');
		$sourceId = Minz_Request::paramInt('id_tag');

		if ($targetName == '' || $sourceId == 0) {
			Minz_Error::error(400);
			return;
		}

		$tagDAO = FreshRSS_Factory::createTagDao();
		$sourceTag = $tagDAO->searchById($sourceId);
		$sourceName = $sourceTag === null ? '' : $sourceTag->name();
		$targetTag = $tagDAO->searchByName($targetName);
		if ($targetTag === null) {
			// There is no existing tag with the same target name
			$tagDAO->updateTagName($sourceId, $targetName);
		} else {
			// There is an existing tag with the same target name
			$tagDAO->updateEntryTag($sourceId, $targetTag->id());
			$tagDAO->deleteTag($sourceId);
		}

		Minz_Request::good(_t('feedback.tag.renamed', $sourceName, $targetName), ['c' => 'tag', 'a' => 'index']);
	}

	public function indexAction(): void {
		FreshRSS_View::prependTitle(_t('sub.menu.label_management') . ' · ');

		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}
		$tagDAO = FreshRSS_Factory::createTagDao();
		$this->view->tags = $tagDAO->listTags(true) ?: [];
	}
}
updateController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/updateController.php'
View Content
<?php
declare(strict_types=1);

class FreshRSS_update_Controller extends FreshRSS_ActionController {

	private const LASTUPDATEFILE = 'last_update.txt';

	public static function isGit(): bool {
		return is_dir(FRESHRSS_PATH . '/.git/');
	}

	/**
	 * Automatic change to the new name of edge branch since FreshRSS 1.18.0,
	 * and perform checks for several git errors.
	 * @throws Minz_Exception
	 */
	public static function migrateToGitEdge(): bool {
		if (!is_writable(FRESHRSS_PATH . '/.git/config')) {
			throw new Minz_Exception('Error during git checkout: .git directory does not seem writeable! ' .
				'Please git pull manually!');
		}

		exec('git --version', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} git not found: Please update manually!");
		}

		//Note `git branch --show-current` requires git 2.22+
		exec('git symbolic-ref --short HEAD 2>&1', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git symbolic-ref: " .
				'Reapply `chown www-data:www-data -R ' . FRESHRSS_PATH . '` ' .
				'or git pull manually! ' .
				json_encode($output, JSON_UNESCAPED_SLASHES));
		}
		$line = implode('', $output);
		if ($line !== 'master' && $line !== 'dev') {
			return true;	// not on master or dev, nothing to do
		}

		Minz_Log::warning('Automatic migration to git edge branch');
		unset($output);
		exec('git checkout edge --guess -f', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git checkout to edge branch! ' .
				'Please change branch manually!");
		}

		unset($output);
		exec('git reset --hard FETCH_HEAD', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git reset! Please git pull manually!");
		}

		return true;
	}

	public static function getCurrentGitBranch(): string {
		$output = [];
		exec('git branch --show-current', $output, $return);
		if ($return === 0) {
			return 'git branch: ' . $output[0];
		} else {
			return 'git';
		}
	}

	public static function hasGitUpdate(): bool {
		$cwd = getcwd();
		if ($cwd === false) {
			Minz_Log::warning('getcwd() failed');
			return false;
		}
		chdir(FRESHRSS_PATH);
		$output = [];
		try {
			/** @throws ValueError */
			exec('git fetch --prune', $output, $return);
			if ($return == 0) {
				$output = [];
				exec('git status -sb --porcelain remote', $output, $return);
			} else {
				$line = implode('; ', $output);
				Minz_Log::warning('git fetch warning: ' . $line);
			}
		} catch (Throwable $e) {
			Minz_Log::warning('git fetch error: ' . $e->getMessage());
		}
		chdir($cwd);
		$line = implode('; ', $output);
		return $line == '' ||
			strpos($line, '[behind') !== false || strpos($line, '[ahead') !== false || strpos($line, '[gone') !== false;
	}

	/** @return string|true */
	public static function gitPull() {
		Minz_Log::notice(_t('admin.update.viaGit'));
		$cwd = getcwd();
		if ($cwd === false) {
			Minz_Log::warning('getcwd() failed');
			return 'getcwd() failed';
		}
		chdir(FRESHRSS_PATH);
		$output = [];
		$return = 1;
		try {
			exec('git fetch --prune', $output, $return);
			if ($return == 0) {
				$output = [];
				exec('git reset --hard FETCH_HEAD', $output, $return);
			}

			$output = [];
			self::migrateToGitEdge();
		} catch (Throwable $e) {
			Minz_Log::warning('Git error: ' . $e->getMessage());
			$output = $e->getMessage();
			$return = 1;
		}
		chdir($cwd);
		$line = is_array($output) ? implode('; ', $output) : $output;
		return $return == 0 ? true : 'Git error: ' . $line;
	}

	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		include_once(LIB_PATH . '/lib_install.php');

		invalidateHttpCache();

		$this->view->is_release_channel_stable = $this->is_release_channel_stable(FRESHRSS_VERSION);

		$this->view->update_to_apply = false;
		$this->view->last_update_time = 'unknown';
		$timestamp = @filemtime(join_path(DATA_PATH, self::LASTUPDATEFILE));
		if ($timestamp !== false) {
			$this->view->last_update_time = timestamptodate($timestamp);
		}
	}

	public function indexAction(): void {
		FreshRSS_View::prependTitle(_t('admin.update.title') . ' · ');

		if (file_exists(UPDATE_FILENAME)) {
			// There is an update file to apply!
			$version = @file_get_contents(join_path(DATA_PATH, self::LASTUPDATEFILE));
			if ($version == '') {
				$version = 'unknown';
			}
			if (@touch(FRESHRSS_PATH . '/index.html')) {
				$this->view->update_to_apply = true;
				$this->view->message = [
					'status' => 'good',
					'title' => _t('gen.short.ok'),
					'body' => _t('feedback.update.can_apply', $version),
				];
			} else {
				$this->view->message = [
					'status' => 'bad',
					'title' => _t('gen.short.damn'),
					'body' => _t('feedback.update.file_is_nok', $version, FRESHRSS_PATH),
				];
			}
		}
	}

	private function is_release_channel_stable(string $currentVersion): bool {
		return strpos($currentVersion, 'dev') === false &&
			strpos($currentVersion, 'edge') === false;
	}

	/*  Check installation if there is a newer version.
		via Git, if available.
		Else via system configuration  auto_update_url
	*/
	public function checkAction(): void {
		FreshRSS_View::prependTitle(_t('admin.update.title') . ' · ');
		$this->view->_path('update/index.phtml');

		if (file_exists(UPDATE_FILENAME)) {
			// There is already an update file to apply: we don’t need to check
			// the webserver!
			// Or if already check during the last hour, do nothing.
			Minz_Request::forward(['c' => 'update'], true);

			return;
		}

		$script = '';

		if (self::isGit()) {
			if (self::hasGitUpdate()) {
				$version = self::getCurrentGitBranch();
			} else {
				$this->view->message = [
					'status' => 'latest',
					'body' => _t('feedback.update.none'),
				];
				@touch(join_path(DATA_PATH, self::LASTUPDATEFILE));
				return;
			}
		} else {
			$auto_update_url = FreshRSS_Context::systemConf()->auto_update_url . '/?v=' . FRESHRSS_VERSION;
			Minz_Log::debug('HTTP GET ' . $auto_update_url);
			$curlResource = curl_init($auto_update_url);

			if ($curlResource === false) {
				Minz_Log::warning('curl_init() failed');
				$this->view->message = [
					'status' => 'bad',
					'title' => _t('gen.short.damn'),
					'body' => _t('feedback.update.server_not_found', $auto_update_url)
				];
				return;
			}
			curl_setopt($curlResource, CURLOPT_RETURNTRANSFER, true);
			curl_setopt($curlResource, CURLOPT_SSL_VERIFYPEER, true);
			curl_setopt($curlResource, CURLOPT_SSL_VERIFYHOST, 2);
			$result = curl_exec($curlResource);
			$curlGetinfo = curl_getinfo($curlResource, CURLINFO_HTTP_CODE);
			$curlError = curl_error($curlResource);
			curl_close($curlResource);

			if ($curlGetinfo !== 200) {
				Minz_Log::warning(
					'Error during update (HTTP code ' . $curlGetinfo . '): ' . $curlError
				);

				$this->view->message = [
					'status' => 'bad',
					'body' => _t('feedback.update.server_not_found', $auto_update_url),
				];
				return;
			}

			$res_array = explode("\n", (string)$result, 2);
			$status = $res_array[0];
			if (strpos($status, 'UPDATE') !== 0) {
				$this->view->message = [
					'status' => 'latest',
					'body' => _t('feedback.update.none'),
				];
				@touch(join_path(DATA_PATH, self::LASTUPDATEFILE));
				return;
			}

			$script = $res_array[1];
			$version = explode(' ', $status, 2);
			$version = $version[1];

			Minz_Log::notice(_t('admin.update.copiedFromURL', $auto_update_url));
		}

		if (file_put_contents(UPDATE_FILENAME, $script) !== false) {
			@file_put_contents(join_path(DATA_PATH, self::LASTUPDATEFILE), $version);
			Minz_Request::forward(['c' => 'update'], true);
		} else {
			$this->view->message = [
				'status' => 'bad',
				'body' => _t('feedback.update.error', 'Cannot save the update script'),
			];
		}
	}

	public function applyAction(): void {
		if (FreshRSS_Context::systemConf()->disable_update || !file_exists(UPDATE_FILENAME) || !touch(FRESHRSS_PATH . '/index.html')) {
			Minz_Request::forward(['c' => 'update'], true);
		}

		if (Minz_Request::paramBoolean('post_conf')) {
			if (self::isGit()) {
				$res = !self::hasGitUpdate();
			} else {
				require(UPDATE_FILENAME);
				// @phpstan-ignore function.notFound
				$res = do_post_update();
			}

			Minz_ExtensionManager::callHookVoid('post_update');

			if ($res === true) {
				@unlink(UPDATE_FILENAME);
				@file_put_contents(join_path(DATA_PATH, self::LASTUPDATEFILE), '');
				Minz_Log::notice(_t('feedback.update.finished'));
				Minz_Request::good(_t('feedback.update.finished'));
			} else {
				Minz_Log::error(_t('feedback.update.error', $res));
				Minz_Request::bad(_t('feedback.update.error', $res), [ 'c' => 'update', 'a' => 'index' ]);
			}
		} else {
			$res = false;

			if (self::isGit()) {
				$res = self::gitPull();
			} else {
				require(UPDATE_FILENAME);
				if (Minz_Request::isPost()) {
					// @phpstan-ignore function.notFound
					save_info_update();
				}
				// @phpstan-ignore function.notFound
				if (!need_info_update()) {
					// @phpstan-ignore function.notFound
					$res = apply_update();
				} else {
					return;
				}
			}

			if (function_exists('opcache_reset')) {
				opcache_reset();
			}

			if ($res === true) {
				Minz_Request::forward([
					'c' => 'update',
					'a' => 'apply',
					'params' => ['post_conf' => '1'],
					], true);
			} else {
				Minz_Log::error(_t('feedback.update.error', $res));
				Minz_Request::bad(_t('feedback.update.error', $res), [ 'c' => 'update', 'a' => 'index' ]);
			}
		}
	}

	/**
	 * This action displays information about installation.
	 */
	public function checkInstallAction(): void {
		FreshRSS_View::prependTitle(_t('admin.check_install.title') . ' · ');

		$this->view->status_php = check_install_php();
		$this->view->status_files = check_install_files();
		$this->view->status_database = check_install_database();
	}
}
userController.php
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/userController.php'
View Content
<?php
declare(strict_types=1);

/**
 * Controller to handle user actions.
 */
class FreshRSS_user_Controller extends FreshRSS_ActionController {
	/**
	 * The username is also used as folder name, file name, and part of SQL table name.
	 * '_' is a reserved internal username.
	 */
	public const USERNAME_PATTERN = '([0-9a-zA-Z_][0-9a-zA-Z_.@\-]{1,38}|[0-9a-zA-Z])';

	public static function checkUsername(string $username): bool {
		return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
	}

	public static function userExists(string $username): bool {
		return @file_exists(USERS_PATH . '/' . $username . '/config.php');
	}

	/** @param array<string,mixed> $userConfigUpdated */
	public static function updateUser(string $user, ?string $email, string $passwordPlain, array $userConfigUpdated = []): bool {
		$userConfig = get_user_configuration($user);
		if ($userConfig === null) {
			return false;
		}

		if ($email !== null && $userConfig->mail_login !== $email) {
			$userConfig->mail_login = $email;

			if (FreshRSS_Context::systemConf()->force_email_validation) {
				$salt = FreshRSS_Context::systemConf()->salt;
				$userConfig->email_validation_token = sha1($salt . uniqid('' . mt_rand(), true));
				$mailer = new FreshRSS_User_Mailer();
				$mailer->send_email_need_validation($user, $userConfig);
			}
		}

		if ($passwordPlain != '') {
			$passwordHash = FreshRSS_password_Util::hash($passwordPlain);
			$userConfig->passwordHash = $passwordHash;
		}

		foreach ($userConfigUpdated as $configName => $configValue) {
			if ($configValue !== null) {
				$userConfig->_param($configName, $configValue);
			}
		}

		$ok = $userConfig->save();
		return $ok;
	}

	public function updateAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		if (Minz_Request::isPost()) {
			$passwordPlain = Minz_Request::paramString('newPasswordPlain', true);
			Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
			$_POST['newPasswordPlain'] = '';

			$username = Minz_Request::paramString('username');
			$ok = self::updateUser($username, null, $passwordPlain, [
				'token' => Minz_Request::paramString('token') ?: null,
			]);

			if ($ok) {
				$isSelfUpdate = Minz_User::name() === $username;
				if ($passwordPlain == '' || !$isSelfUpdate) {
					Minz_Request::good(_t('feedback.user.updated', $username), ['c' => 'user', 'a' => 'manage']);
				} else {
					Minz_Request::good(_t('feedback.profile.updated'), ['c' => 'index', 'a' => 'index']);
				}
			} else {
				Minz_Request::bad(_t('feedback.user.updated.error', $username), ['c' => 'user', 'a' => 'manage']);
			}
		}
	}

	/**
	 * This action displays the user profile page.
	 */
	public function profileAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		$email_not_verified = FreshRSS_Context::userConf()->email_validation_token != '';
		$this->view->disable_aside = false;
		if ($email_not_verified) {
			$this->view->_layout('simple');
			$this->view->disable_aside = true;
		}

		FreshRSS_View::prependTitle(_t('conf.profile.title') . ' · ');

		FreshRSS_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')));

		if (Minz_Request::isPost() && Minz_User::name() != null) {
			$old_email = FreshRSS_Context::userConf()->mail_login;

			$email = Minz_Request::paramString('email');
			$passwordPlain = Minz_Request::paramString('newPasswordPlain', true);
			Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
			$_POST['newPasswordPlain'] = '';

			if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) {
				Minz_Request::bad(
					_t('user.email.feedback.required'),
					['c' => 'user', 'a' => 'profile']
				);
			}

			if (!empty($email) && !validateEmailAddress($email)) {
				Minz_Request::bad(
					_t('user.email.feedback.invalid'),
					['c' => 'user', 'a' => 'profile']
				);
			}

			$ok = self::updateUser(
				Minz_User::name(),
				$email,
				$passwordPlain,
				[
					'token' => Minz_Request::paramString('token'),
				]
			);

			Minz_Session::_param('passwordHash', FreshRSS_Context::userConf()->passwordHash);

			if ($ok) {
				if (FreshRSS_Context::systemConf()->force_email_validation && $email !== $old_email) {
					Minz_Request::good(_t('feedback.profile.updated'), ['c' => 'user', 'a' => 'validateEmail']);
				} elseif ($passwordPlain == '') {
					Minz_Request::good(_t('feedback.profile.updated'), ['c' => 'user', 'a' => 'profile']);
				} else {
					Minz_Request::good(_t('feedback.profile.updated'), ['c' => 'index', 'a' => 'index']);
				}
			} else {
				Minz_Request::bad(_t('feedback.profile.error'), ['c' => 'user', 'a' => 'profile']);
			}
		}
	}

	public function purgeAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		if (Minz_Request::isPost()) {
			$username = Minz_Request::paramString('username');

			if (!FreshRSS_UserDAO::exists($username)) {
				Minz_Error::error(404);
			}

			$feedDAO = FreshRSS_Factory::createFeedDao($username);
			$feedDAO->purge();
		}
	}

	/**
	 * This action displays the user management page.
	 */
	public function manageAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		FreshRSS_View::prependTitle(_t('admin.user.title') . ' · ');

		if (Minz_Request::isPost()) {
			$action = Minz_Request::paramString('action');
			switch ($action) {
				case 'delete':
					$this->deleteAction();
					break;
				case 'update':
					$this->updateAction();
					break;
				case 'purge':
					$this->purgeAction();
					break;
				case 'promote':
					$this->promoteAction();
					break;
				case 'demote':
					$this->demoteAction();
					break;
				case 'enable':
					$this->enableAction();
					break;
				case 'disable':
					$this->disableAction();
					break;
			}
		}

		$this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation;
		$this->view->current_user = Minz_Request::paramString('u');

		foreach (listUsers() as $user) {
			$this->view->users[$user] = $this->retrieveUserDetails($user);
		}
	}

	/**
	 * @param array<string,mixed> $userConfigOverride
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public static function createUser(string $new_user_name, ?string $email, string $passwordPlain,
		array $userConfigOverride = [], bool $insertDefaultFeeds = true): bool {
		$userConfig = [];

		$customUserConfigPath = join_path(DATA_PATH, 'config-user.custom.php');
		if (file_exists($customUserConfigPath)) {
			$customUserConfig = include($customUserConfigPath);
			if (is_array($customUserConfig)) {
				$userConfig = $customUserConfig;
			}
		}

		$userConfig = array_merge($userConfig, $userConfigOverride);

		$ok = self::checkUsername($new_user_name);
		$homeDir = join_path(DATA_PATH, 'users', $new_user_name);
		$configPath = '';

		if ($ok) {
			$languages = Minz_Translate::availableLanguages();
			if (empty($userConfig['language']) || !in_array($userConfig['language'], $languages, true)) {
				$userConfig['language'] = 'en';
			}

			$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()), true);	//Not an existing user, case-insensitive

			$configPath = join_path($homeDir, 'config.php');
			$ok &= !file_exists($configPath);
		}
		if ($ok) {
			if (!is_dir($homeDir)) {
				mkdir($homeDir, 0770, true);
			}
			$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
		}
		if ($ok) {
			$newUserDAO = FreshRSS_Factory::createUserDao($new_user_name);
			$ok &= $newUserDAO->createUser();

			if ($ok && $insertDefaultFeeds) {
				$opmlPath = DATA_PATH . '/opml.xml';
				if (!file_exists($opmlPath)) {
					$opmlPath = FRESHRSS_PATH . '/opml.default.xml';
				}
				$importController = new FreshRSS_importExport_Controller();
				try {
					$importController->importFile($opmlPath, $opmlPath, $new_user_name);
				} catch (Exception $e) {
					Minz_Log::error('Error while importing default OPML for user ' . $new_user_name . ': ' . $e->getMessage());
				}
			}

			$ok &= self::updateUser($new_user_name, $email, $passwordPlain);
		}
		return (bool)$ok;
	}

	/**
	 * This action creates a new user.
	 *
	 * Request parameters are:
	 *   - new_user_language
	 *   - new_user_name
	 *   - new_user_email
	 *   - new_user_passwordPlain
	 *   - r (i.e. a redirection url, optional)
	 *
	 * @todo clean up this method. Idea: write a method to init a user with basic information.
	 */
	public function createAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin') && max_registrations_reached()) {
			Minz_Error::error(403);
		}

		if (Minz_Request::isPost()) {
			$new_user_name = Minz_Request::paramString('new_user_name');
			$email = Minz_Request::paramString('new_user_email');
			$passwordPlain = Minz_Request::paramString('new_user_passwordPlain', true);
			$badRedirectUrl = [
				'c' => Minz_Request::paramString('originController') ?: 'auth',
				'a' => Minz_Request::paramString('originAction') ?: 'register',
			];

			if (!self::checkUsername($new_user_name)) {
				Minz_Request::bad(
					_t('user.username.invalid'),
					$badRedirectUrl
				);
			}

			if (FreshRSS_UserDAO::exists($new_user_name)) {
				Minz_Request::bad(
					_t('user.username.taken', $new_user_name),
					$badRedirectUrl
				);
			}

			if (!FreshRSS_password_Util::check($passwordPlain)) {
				Minz_Request::bad(
					_t('user.password.invalid'),
					$badRedirectUrl
				);
			}

			if (!FreshRSS_Auth::hasAccess('admin')) {
				// TODO: We may want to ask the user to accept TOS before first login
				$tos_enabled = file_exists(TOS_FILENAME);
				$accept_tos = Minz_Request::paramBoolean('accept_tos');
				if ($tos_enabled && !$accept_tos) {
					Minz_Request::bad(_t('user.tos.feedback.invalid'), $badRedirectUrl);
				}
			}

			if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) {
				Minz_Request::bad(
					_t('user.email.feedback.required'),
					$badRedirectUrl
				);
			}

			if (!empty($email) && !validateEmailAddress($email)) {
				Minz_Request::bad(
					_t('user.email.feedback.invalid'),
					$badRedirectUrl
				);
			}

			$ok = self::createUser($new_user_name, $email, $passwordPlain, [
				'language' => Minz_Request::paramString('new_user_language') ?: FreshRSS_Context::userConf()->language,
				'timezone' => Minz_Request::paramString('new_user_timezone'),
				'is_admin' => Minz_Request::paramBoolean('new_user_is_admin'),
				'enabled' => true,
			]);
			Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
			$_POST['new_user_passwordPlain'] = '';
			invalidateHttpCache();

			// If the user has admin access, it means he’s already logged in
			// and we don’t want to login with the new account. Otherwise, the
			// user just created its account himself so he probably wants to
			// get started immediately.
			if ($ok && !FreshRSS_Auth::hasAccess('admin')) {
				$user_conf = get_user_configuration($new_user_name);
				if ($user_conf !== null) {
					Minz_Session::_params([
						Minz_User::CURRENT_USER => $new_user_name,
						'passwordHash' => $user_conf->passwordHash,
						'csrf' => false,
					]);
					FreshRSS_Auth::giveAccess();
				} else {
					$ok = false;
				}
			}

			if ($ok) {
				Minz_Request::setGoodNotification(_t('feedback.user.created', $new_user_name));
			} else {
				Minz_Request::setBadNotification(_t('feedback.user.created.error', $new_user_name));
			}
		}

		$redirect_url = ['c' => 'user', 'a' => 'manage'];
		Minz_Request::forward($redirect_url, true);
	}

	public static function deleteUser(string $username): bool {
		$ok = self::checkUsername($username);
		if ($ok) {
			$default_user = FreshRSS_Context::systemConf()->default_user;
			$ok &= (strcasecmp($username, $default_user) !== 0);	//It is forbidden to delete the default user
		}
		$user_data = join_path(DATA_PATH, 'users', $username);
		$ok &= is_dir($user_data);
		if ($ok) {
			FreshRSS_fever_Util::deleteKey($username);
			Minz_ModelPdo::$usesSharedPdo = false;
			$oldUserDAO = FreshRSS_Factory::createUserDao($username);
			$ok &= $oldUserDAO->deleteUser();
			Minz_ModelPdo::$usesSharedPdo = true;
			$ok &= recursive_unlink($user_data);
			$filenames = glob(PSHB_PATH . '/feeds/*/' . $username . '.txt');
			if (!empty($filenames)) {
				array_map('unlink', $filenames);
			}
		}
		return (bool)$ok;
	}

	/**
	 * This action validates an email address, based on the token sent by email.
	 * It also serves the main page when user is blocked.
	 *
	 * Request parameters are:
	 *   - username
	 *   - token
	 *
	 * This route works with GET requests since the URL is provided by email.
	 * The security risks (e.g. forged URL by an attacker) are not very high so
	 * it’s ok.
	 *
	 * It returns 404 error if `force_email_validation` is disabled or if the
	 * user doesn’t exist.
	 *
	 * It returns 403 if user isn’t logged in and `username` param isn’t passed.
	 */
	public function validateEmailAction(): void {
		if (!FreshRSS_Context::systemConf()->force_email_validation) {
			Minz_Error::error(404);
		}

		FreshRSS_View::prependTitle(_t('user.email.validation.title') . ' · ');
		$this->view->_layout('simple');

		$username = Minz_Request::paramString('username');
		$token = Minz_Request::paramString('token');

		if ($username !== '') {
			$user_config = get_user_configuration($username);
		} elseif (FreshRSS_Auth::hasAccess()) {
			$user_config = FreshRSS_Context::userConf();
		} else {
			Minz_Error::error(403);
			return;
		}

		if (!FreshRSS_UserDAO::exists($username) || $user_config === null) {
			Minz_Error::error(404);
			return;
		}

		if ($user_config->email_validation_token === '') {
			Minz_Request::good(
				_t('user.email.validation.feedback.unnecessary'),
				['c' => 'index', 'a' => 'index']
			);
		}

		if ($token != '') {
			if ($user_config->email_validation_token !== $token) {
				Minz_Request::bad(
					_t('user.email.validation.feedback.wrong_token'),
					['c' => 'user', 'a' => 'validateEmail']
				);
			}

			$user_config->email_validation_token = '';
			if ($user_config->save()) {
				Minz_Request::good(
					_t('user.email.validation.feedback.ok'),
					['c' => 'index', 'a' => 'index']
				);
			} else {
				Minz_Request::bad(
					_t('user.email.validation.feedback.error'),
					['c' => 'user', 'a' => 'validateEmail']
				);
			}
		}
	}

	/**
	 * This action resends a validation email to the current user.
	 *
	 * It only acts on POST requests but doesn’t require any param (except the
	 * CSRF token).
	 *
	 * It returns 403 error if the user is not logged in or 404 if request is
	 * not POST. Else it redirects silently to the index if user has already
	 * validated its email, or to the user#validateEmail route.
	 */
	public function sendValidationEmailAction(): void {
		if (!FreshRSS_Auth::hasAccess()) {
			Minz_Error::error(403);
		}

		if (!Minz_Request::isPost()) {
			Minz_Error::error(404);
		}

		$username = Minz_User::name();

		if (FreshRSS_Context::userConf()->email_validation_token === '') {
			Minz_Request::forward([
				'c' => 'index',
				'a' => 'index',
			], true);
		}

		$mailer = new FreshRSS_User_Mailer();
		$ok = $username != null && $mailer->send_email_need_validation($username, FreshRSS_Context::userConf());

		$redirect_url = ['c' => 'user', 'a' => 'validateEmail'];
		if ($ok) {
			Minz_Request::good(
				_t('user.email.validation.feedback.email_sent'),
				$redirect_url
			);
		} else {
			Minz_Request::bad(
				_t('user.email.validation.feedback.email_failed'),
				$redirect_url
			);
		}
	}

	/**
	 * This action delete an existing user.
	 *
	 * Request parameter is:
	 *   - username
	 *
	 * @todo clean up this method. Idea: create a User->clean() method.
	 */
	public function deleteAction(): void {
		$username = Minz_Request::paramString('username');
		$self_deletion = Minz_User::name() === $username;

		if (!FreshRSS_Auth::hasAccess('admin') && !$self_deletion) {
			Minz_Error::error(403);
		}

		$redirect_url = ['c' => 'user', 'a' => 'manage'];

		if (Minz_Request::isPost()) {
			$ok = true;
			if ($self_deletion) {
				// We check the password if it’s a self-destruction
				$nonce = Minz_Session::paramString('nonce');
				$challenge = Minz_Request::paramString('challenge');

				$ok &= FreshRSS_FormAuth::checkCredentials(
					$username, FreshRSS_Context::userConf()->passwordHash,
					$nonce, $challenge
				);
			}
			if ($ok) {
				$ok &= self::deleteUser($username);
			}
			if ($ok && $self_deletion) {
				FreshRSS_Auth::removeAccess();
				$redirect_url = ['c' => 'index', 'a' => 'index'];
			}
			invalidateHttpCache();

			if ($ok) {
				Minz_Request::setGoodNotification(_t('feedback.user.deleted', $username));
			} else {
				Minz_Request::setBadNotification(_t('feedback.user.deleted.error', $username));
			}
		}

		Minz_Request::forward($redirect_url, true);
	}

	public function promoteAction(): void {
		$this->toggleAction('is_admin', true);
	}

	public function demoteAction(): void {
		$this->toggleAction('is_admin', false);
	}

	public function enableAction(): void {
		$this->toggleAction('enabled', true);
	}

	public function disableAction(): void {
		$this->toggleAction('enabled', false);
	}

	private function toggleAction(string $field, bool $value): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		if (!Minz_Request::isPost()) {
			Minz_Error::error(403);
		}

		$username = Minz_Request::paramString('username');
		if (!FreshRSS_UserDAO::exists($username)) {
			Minz_Error::error(404);
		}

		if (null === $userConfig = get_user_configuration($username)) {
			Minz_Error::error(500);
			return;
		}

		$userConfig->_param($field, $value);

		$ok = $userConfig->save();
		FreshRSS_UserDAO::touch($username);

		if ($ok) {
			Minz_Request::good(_t('feedback.user.updated', $username), ['c' => 'user', 'a' => 'manage']);
		} else {
			Minz_Request::bad(
				_t('feedback.user.updated.error', $username),
				['c' => 'user', 'a' => 'manage']
			);
		}
	}

	public function detailsAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		$username = Minz_Request::paramString('username');
		if (!FreshRSS_UserDAO::exists($username)) {
			Minz_Error::error(404);
		}

		if (Minz_Request::paramBoolean('ajax')) {
			$this->view->_layout(null);
		}

		$this->view->username = $username;
		$this->view->details = $this->retrieveUserDetails($username);
		FreshRSS_View::prependTitle($username . ' · ' . _t('gen.menu.user_management') . ' · ');
	}

	/** @return array{'feed_count':int,'article_count':int,'database_size':int,'language':string,'mail_login':string,'enabled':bool,'is_admin':bool,'last_user_activity':string,'is_default':bool} */
	private function retrieveUserDetails(string $username): array {
		$feedDAO = FreshRSS_Factory::createFeedDao($username);
		$entryDAO = FreshRSS_Factory::createEntryDao($username);
		$databaseDAO = FreshRSS_Factory::createDatabaseDAO($username);

		$userConfiguration = get_user_configuration($username);
		if ($userConfiguration === null) {
			throw new Exception('Error loading user configuration!');
		}

		return [
			'feed_count' => $feedDAO->count(),
			'article_count' => $entryDAO->count(),
			'database_size' => $databaseDAO->size(),
			'language' => $userConfiguration->language,
			'mail_login' => $userConfiguration->mail_login,
			'enabled' => $userConfiguration->enabled,
			'is_admin' => $userConfiguration->is_admin,
			'last_user_activity' => date('c', FreshRSS_UserDAO::mtime($username)) ?: '',
			'is_default' => FreshRSS_Context::systemConf()->default_user === $username,
		];
	}
}