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`).
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/apiController.php'
<?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);
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/authController.php'
<?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') ?: '';
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/categoryController.php'
<?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,
];
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/configureController.php'
<?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' ]);
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/entryController.php'
<?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',
]);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/errorController.php'
<?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 . ' · ');
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/extensionController.php'
<?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);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/feedController.php'
<?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()), ['<' => '<', '>' => '>', '"' => '"']);
$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');
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/importExportController.php'
<?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';
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/indexController.php'
<?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);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/javascriptController.php'
<?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());
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/statsController.php'
<?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;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/subscriptionController.php'
<?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') . ' . ');
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/tagController.php'
<?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) ?: [];
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/updateController.php'
<?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();
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Controllers/userController.php'
<?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,
];
}
}