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/Models/ActionController.php'
<?php
declare(strict_types=1);
abstract class FreshRSS_ActionController extends Minz_ActionController {
/**
* @var FreshRSS_View
*/
protected $view;
public function __construct(string $viewType = '') {
parent::__construct($viewType === '' ? FreshRSS_View::class : $viewType);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/AttributesTrait.php'
<?php
declare(strict_types=1);
/**
* Logic to work with (JSON) attributes (for entries, feeds, categories, tags...).
*/
trait FreshRSS_AttributesTrait {
/**
* @var array<string,mixed>
*/
private array $attributes = [];
/** @return array<string,mixed> */
public function attributes(): array {
return $this->attributes;
}
/** @param non-empty-string $key */
public function hasAttribute(string $key): bool {
return isset($this->attributes[$key]);
}
/**
* @param non-empty-string $key
* @return array<int|string,mixed>|null
*/
public function attributeArray(string $key): ?array {
$a = $this->attributes[$key] ?? null;
return is_array($a) ? $a : null;
}
/** @param non-empty-string $key */
public function attributeBoolean(string $key): ?bool {
$a = $this->attributes[$key] ?? null;
return is_bool($a) ? $a : null;
}
/** @param non-empty-string $key */
public function attributeInt(string $key): ?int {
$a = $this->attributes[$key] ?? null;
return is_int($a) ? $a : null;
}
/** @param non-empty-string $key */
public function attributeString(string $key): ?string {
$a = $this->attributes[$key] ?? null;
return is_string($a) ? $a : null;
}
/** @param string|array<string,mixed> $values Values, not HTML-encoded */
public function _attributes($values): void {
if (is_string($values)) {
$values = json_decode($values, true);
}
if (is_array($values)) {
$this->attributes = $values;
}
}
/**
* @param non-empty-string $key
* @param array<string,mixed>|mixed|null $value Value, not HTML-encoded
*/
public function _attribute(string $key, $value = null): void {
if ($value === null) {
unset($this->attributes[$key]);
} else {
$this->attributes[$key] = $value;
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Auth.php'
<?php
declare(strict_types=1);
/**
* This class handles all authentication process.
*/
class FreshRSS_Auth {
/**
* Determines if user is connected.
*/
public const DEFAULT_COOKIE_DURATION = 7_776_000;
private static bool $login_ok = false;
/**
* This method initializes authentication system.
*/
public static function init(): bool {
if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== httpAuthUser()) {
//HTTP REMOTE_USER has changed
self::removeAccess();
}
self::$login_ok = Minz_Session::paramBoolean('loginOk');
$current_user = Minz_User::name();
if ($current_user === null) {
$current_user = FreshRSS_Context::systemConf()->default_user;
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
'csrf' => false,
]);
}
if (self::$login_ok && self::giveAccess()) {
return self::$login_ok;
}
if (self::accessControl() && self::giveAccess()) {
FreshRSS_UserDAO::touch();
return self::$login_ok;
}
// Be sure all accesses are removed!
self::removeAccess();
return false;
}
/**
* This method checks if user is allowed to connect.
*
* Required session parameters are also set in this method (such as
* currentUser).
*
* @return bool true if user can be connected, false otherwise.
*/
private static function accessControl(): bool {
$auth_type = FreshRSS_Context::systemConf()->auth_type;
switch ($auth_type) {
case 'form':
$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
$current_user = '';
if (isset($credentials[1])) {
$current_user = trim($credentials[0]);
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
'passwordHash' => trim($credentials[1]),
'csrf' => false,
]);
}
return $current_user != '';
case 'http_auth':
$current_user = httpAuthUser();
if ($current_user == '') {
return false;
}
$login_ok = FreshRSS_UserDAO::exists($current_user);
if (!$login_ok && FreshRSS_Context::systemConf()->http_auth_auto_register) {
$email = null;
if (FreshRSS_Context::systemConf()->http_auth_auto_register_email_field !== '' &&
isset($_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field])) {
$email = (string)$_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field];
}
$language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
Minz_Translate::init($language);
$login_ok = FreshRSS_user_Controller::createUser($current_user, $email, '', [
'language' => $language,
]);
}
if ($login_ok) {
Minz_Session::_params([
Minz_User::CURRENT_USER => $current_user,
'csrf' => false,
]);
}
return $login_ok;
case 'none':
return true;
default:
// TODO load extension
return false;
}
}
/**
* Gives access to the current user.
*/
public static function giveAccess(): bool {
FreshRSS_Context::initUser();
if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
self::$login_ok = false;
return false;
}
switch (FreshRSS_Context::systemConf()->auth_type) {
case 'form':
self::$login_ok = Minz_Session::paramString('passwordHash') === FreshRSS_Context::userConf()->passwordHash;
break;
case 'http_auth':
$current_user = Minz_User::name() ?? '';
self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0;
break;
case 'none':
self::$login_ok = true;
break;
default:
// TODO: extensions
self::$login_ok = false;
}
Minz_Session::_params([
'loginOk' => self::$login_ok,
'REMOTE_USER' => httpAuthUser(),
]);
return self::$login_ok;
}
/**
* Returns if current user has access to the given scope.
*
* @param string $scope general (default) or admin
* @return bool true if user has corresponding access, false else.
*/
public static function hasAccess(string $scope = 'general'): bool {
if (!FreshRSS_Context::hasUserConf()) {
return false;
}
$currentUser = Minz_User::name();
$isAdmin = FreshRSS_Context::userConf()->is_admin;
$default_user = FreshRSS_Context::systemConf()->default_user;
$ok = self::$login_ok;
switch ($scope) {
case 'general':
break;
case 'admin':
$ok &= $default_user === $currentUser || $isAdmin;
break;
default:
$ok = false;
}
return (bool)$ok;
}
/**
* Removes all accesses for the current user.
*/
public static function removeAccess(): void {
self::$login_ok = false;
Minz_Session::_params([
'loginOk' => false,
'csrf' => false,
'REMOTE_USER' => false,
]);
$username = '';
$token_param = Minz_Request::paramString('token');
if ($token_param != '') {
$username = Minz_Request::paramString('user');
if ($username != '') {
$conf = get_user_configuration($username);
if ($conf == null) {
$username = '';
}
}
}
if ($username == '') {
$username = FreshRSS_Context::systemConf()->default_user;
}
Minz_User::change($username);
switch (FreshRSS_Context::systemConf()->auth_type) {
case 'form':
Minz_Session::_param('passwordHash');
FreshRSS_FormAuth::deleteCookie();
break;
case 'http_auth':
case 'none':
// Nothing to doβ¦
break;
default:
// TODO: extensions
}
}
/**
* Return if authentication is enabled on this instance of FRSS.
*/
public static function accessNeedsLogin(): bool {
return FreshRSS_Context::systemConf()->auth_type !== 'none';
}
/**
* Return if authentication requires a PHP action.
*/
public static function accessNeedsAction(): bool {
return FreshRSS_Context::systemConf()->auth_type === 'form';
}
public static function csrfToken(): string {
$csrf = Minz_Session::paramString('csrf');
if ($csrf == '') {
$salt = FreshRSS_Context::systemConf()->salt;
$csrf = sha1($salt . uniqid('' . random_int(0, mt_getrandmax()), true));
Minz_Session::_param('csrf', $csrf);
}
return $csrf;
}
public static function isCsrfOk(?string $token = null): bool {
$csrf = Minz_Session::paramString('csrf');
if ($token === null) {
$token = $_POST['_csrf'] ?? '';
}
return $token != '' && $token === $csrf;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/BooleanSearch.php'
<?php
declare(strict_types=1);
/**
* Contains Boolean search from the search form.
*/
class FreshRSS_BooleanSearch {
private string $raw_input = '';
/** @var array<FreshRSS_BooleanSearch|FreshRSS_Search> */
private array $searches = [];
/**
* @phpstan-var 'AND'|'OR'|'AND NOT'|'OR NOT'
*/
private string $operator;
/** @param 'AND'|'OR'|'AND NOT'|'OR NOT' $operator */
public function __construct(string $input, int $level = 0, string $operator = 'AND', bool $allowUserQueries = true) {
$this->operator = $operator;
$input = trim($input);
if ($input === '') {
return;
}
if ($level === 0) {
$input = preg_replace('/:"(.*?)"/', ':"\1"', $input);
if (!is_string($input)) {
return;
}
$input = preg_replace('/(?<=[\s!-]|^)"(.*?)"/', '"\1"', $input);
if (!is_string($input)) {
return;
}
$input = $this->parseUserQueryNames($input, $allowUserQueries);
$input = $this->parseUserQueryIds($input, $allowUserQueries);
$input = trim($input);
}
$this->raw_input = $input;
$input = self::consistentOrParentheses($input);
// Either parse everything as a series of BooleanSearchβs combined by implicit AND
// or parse everything as a series of Searchβs combined by explicit OR
$this->parseParentheses($input, $level) || $this->parseOrSegments($input);
}
/**
* Parse the user queries (saved searches) by name and expand them in the input string.
*/
private function parseUserQueryNames(string $input, bool $allowUserQueries = true): string {
$all_matches = [];
if (preg_match_all('/\bsearch:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matchesFound)) {
$all_matches[] = $matchesFound;
}
if (preg_match_all('/\bsearch:(?P<search>[^\s"]*)/', $input, $matchesFound)) {
$all_matches[] = $matchesFound;
}
if (!empty($all_matches)) {
/** @var array<string,FreshRSS_UserQuery> */
$queries = [];
foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
$queries[$query->getName()] = $query;
}
$fromS = [];
$toS = [];
foreach ($all_matches as $matches) {
if (empty($matches['search'])) {
continue;
}
for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
$name = trim($matches['search'][$i]);
if (!empty($queries[$name])) {
$fromS[] = $matches[0][$i];
if ($allowUserQueries) {
$toS[] = '(' . trim($queries[$name]->getSearch()->getRawInput()) . ')';
} else {
$toS[] = '';
}
}
}
}
$input = str_replace($fromS, $toS, $input);
}
return $input;
}
/**
* Parse the user queries (saved searches) by ID and expand them in the input string.
*/
private function parseUserQueryIds(string $input, bool $allowUserQueries = true): string {
$all_matches = [];
if (preg_match_all('/\bS:(?P<search>\d+)/', $input, $matchesFound)) {
$all_matches[] = $matchesFound;
}
if (!empty($all_matches)) {
/** @var array<string,FreshRSS_UserQuery> */
$queries = [];
foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
$queries[] = $query;
}
$fromS = [];
$toS = [];
foreach ($all_matches as $matches) {
if (empty($matches['search'])) {
continue;
}
for ($i = count($matches['search']) - 1; $i >= 0; $i--) {
// Index starting from 1
$id = (int)(trim($matches['search'][$i])) - 1;
if (!empty($queries[$id])) {
$fromS[] = $matches[0][$i];
if ($allowUserQueries) {
$toS[] = '(' . trim($queries[$id]->getSearch()->getRawInput()) . ')';
} else {
$toS[] = '';
}
}
}
}
$input = str_replace($fromS, $toS, $input);
}
return $input;
}
/**
* Example: 'ab cd OR ef OR "gh ij"' becomes '(ab cd) OR (ef) OR ("gh ij")'
*/
public static function addOrParentheses(string $input): string {
$input = trim($input);
if ($input === '') {
return '';
}
$splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
$ns = count($splits);
if ($ns <= 1) {
return $input;
}
$result = '';
$segment = '';
for ($i = 0; $i < $ns; $i++) {
$segment .= $splits[$i];
if (trim($segment) === '') {
$segment = '';
} elseif (strcasecmp($segment, 'OR') === 0) {
$result .= $segment . ' ';
$segment = '';
} else {
$quotes = substr_count($segment, '"') + substr_count($segment, '"');
if ($quotes % 2 === 0) {
$segment = trim($segment);
if (in_array($segment, ['!', '-'], true)) {
$result .= $segment;
} else {
$result .= '(' . $segment . ') ';
}
$segment = '';
}
}
}
$segment = trim($segment);
if (in_array($segment, ['!', '-'], true)) {
$result .= $segment;
} elseif ($segment !== '') {
$result .= '(' . $segment . ')';
}
return trim($result);
}
/**
* If the query contains a mix of `OR` expressions with and without parentheses,
* then add parentheses to make the query consistent.
* Example: '(ab (cd OR ef)) OR gh OR ij OR (kl)' becomes '(ab ((cd) OR (ef))) OR (gh) OR (ij) OR (kl)'
*/
public static function consistentOrParentheses(string $input): string {
if (!preg_match('/(?<!\\\\)\\(/', $input)) {
// No unescaped parentheses in the input
return trim($input);
}
$parenthesesCount = 0;
$result = '';
$segment = '';
$length = strlen($input);
for ($i = 0; $i < $length; $i++) {
$c = $input[$i];
$backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
if (!$backslashed) {
if ($c === '(') {
if ($parenthesesCount === 0) {
if ($segment !== '') {
$result = rtrim($result) . ' ' . self::addOrParentheses($segment);
$negation = preg_match('/[!-]$/', $result);
if (!$negation) {
$result .= ' ';
}
$segment = '';
}
$c = '';
}
$parenthesesCount++;
} elseif ($c === ')') {
$parenthesesCount--;
if ($parenthesesCount === 0) {
$segment = self::consistentOrParentheses($segment);
if ($segment !== '') {
$result .= '(' . $segment . ')';
$segment = '';
}
$c = '';
}
}
}
$segment .= $c;
}
if (trim($segment) !== '') {
$result = rtrim($result);
$negation = preg_match('/[!-]$/', $segment);
if (!$negation) {
$result .= ' ';
}
$result .= self::addOrParentheses($segment);
}
return trim($result);
}
/** @return bool True if some parenthesis logic took over, false otherwise */
private function parseParentheses(string $input, int $level): bool {
$input = trim($input);
$length = strlen($input);
$i = 0;
$before = '';
$hasParenthesis = false;
$nextOperator = 'AND';
while ($i < $length) {
$c = $input[$i];
$backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
if ($c === '(' && !$backslashed) {
$hasParenthesis = true;
$before = trim($before);
if (preg_match('/[!-]$/', $before)) {
// Trim trailing negation
$before = rtrim($before, ' !-');
$isOr = preg_match('/\bOR$/i', $before);
if ($isOr) {
// Trim trailing OR
$before = substr($before, 0, -2);
}
// The text prior to the negation is a BooleanSearch
$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
if (count($searchBefore->searches()) > 0) {
$this->searches[] = $searchBefore;
}
$before = '';
// The next BooleanSearch will have to be combined with AND NOT or OR NOT instead of default AND
$nextOperator = $isOr ? 'OR NOT' : 'AND NOT';
} elseif (preg_match('/\bOR$/i', $before)) {
// Trim trailing OR
$before = substr($before, 0, -2);
// The text prior to the OR is a BooleanSearch
$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
if (count($searchBefore->searches()) > 0) {
$this->searches[] = $searchBefore;
}
$before = '';
// The next BooleanSearch will have to be combined with OR instead of default AND
$nextOperator = 'OR';
} elseif ($before !== '') {
// The text prior to the opening parenthesis is a BooleanSearch
$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
if (count($searchBefore->searches()) > 0) {
$this->searches[] = $searchBefore;
}
$before = '';
}
// Search the matching closing parenthesis
$parentheses = 1;
$sub = '';
$i++;
while ($i < $length) {
$c = $input[$i];
$backslashed = $input[$i - 1] === '\\';
if ($c === '(' && !$backslashed) {
// One nested level deeper
$parentheses++;
$sub .= $c;
} elseif ($c === ')' && !$backslashed) {
$parentheses--;
if ($parentheses === 0) {
// Found the matching closing parenthesis
$searchSub = new FreshRSS_BooleanSearch($sub, $level + 1, $nextOperator);
$nextOperator = 'AND';
if (count($searchSub->searches()) > 0) {
$this->searches[] = $searchSub;
}
$sub = '';
break;
} else {
$sub .= $c;
}
} else {
$sub .= $c;
}
$i++;
}
// $sub = trim($sub);
// if ($sub !== '') {
// // TODO: Consider throwing an error or warning in case of non-matching parenthesis
// }
// } elseif ($c === ')') {
// // TODO: Consider throwing an error or warning in case of non-matching parenthesis
} else {
$before .= $c;
}
$i++;
}
if ($hasParenthesis) {
$before = trim($before);
if (preg_match('/^OR\b/i', $before)) {
// The next BooleanSearch will have to be combined with OR instead of default AND
$nextOperator = 'OR';
// Trim leading OR
$before = substr($before, 2);
}
// The remaining text after the last parenthesis is a BooleanSearch
$searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator);
$nextOperator = 'AND';
if (count($searchBefore->searches()) > 0) {
$this->searches[] = $searchBefore;
}
return true;
}
// There was no parenthesis logic to apply
return false;
}
private function parseOrSegments(string $input): void {
$input = trim($input);
if ($input === '') {
return;
}
$splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
$segment = '';
$ns = count($splits);
for ($i = 0; $i < $ns; $i++) {
$segment = $segment . $splits[$i];
if (trim($segment) === '' || strcasecmp($segment, 'OR') === 0) {
$segment = '';
} else {
$quotes = substr_count($segment, '"') + substr_count($segment, '"');
if ($quotes % 2 === 0) {
$segment = trim($segment);
$this->searches[] = new FreshRSS_Search($segment);
$segment = '';
}
}
}
$segment = trim($segment);
if ($segment !== '') {
$this->searches[] = new FreshRSS_Search($segment);
}
}
/**
* Either a list of FreshRSS_BooleanSearch combined by implicit AND
* or a series of FreshRSS_Search combined by explicit OR
* @return array<FreshRSS_BooleanSearch|FreshRSS_Search>
*/
public function searches(): array {
return $this->searches;
}
/** @return 'AND'|'OR'|'AND NOT'|'OR NOT' depending on how this BooleanSearch should be combined */
public function operator(): string {
return $this->operator;
}
/** @param FreshRSS_BooleanSearch|FreshRSS_Search $search */
public function add($search): void {
$this->searches[] = $search;
}
#[\Override]
public function __toString(): string {
return $this->getRawInput();
}
public function getRawInput(): string {
return $this->raw_input;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Category.php'
<?php
declare(strict_types=1);
class FreshRSS_Category extends Minz_Model {
use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
/**
* Normal
*/
public const KIND_NORMAL = 0;
/**
* Category tracking a third-party Dynamic OPML
*/
public const KIND_DYNAMIC_OPML = 2;
private int $id = 0;
private int $kind = 0;
private string $name;
private int $nbFeeds = -1;
private int $nbNotRead = -1;
/** @var array<FreshRSS_Feed>|null */
private ?array $feeds = null;
/** @var bool|int */
private $hasFeedsWithError = false;
private int $lastUpdate = 0;
private bool $error = false;
/**
* @param array<FreshRSS_Feed>|null $feeds
*/
public function __construct(string $name = '', int $id = 0, ?array $feeds = null) {
$this->_id($id);
$this->_name($name);
if ($feeds !== null) {
$this->_feeds($feeds);
$this->nbFeeds = 0;
$this->nbNotRead = 0;
foreach ($feeds as $feed) {
$feed->_category($this);
$this->nbFeeds++;
$this->nbNotRead += $feed->nbNotRead();
$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
}
}
}
public function id(): int {
return $this->id;
}
public function kind(): int {
return $this->kind;
}
/** @return string HTML-encoded name of the category */
public function name(): string {
return $this->name;
}
public function lastUpdate(): int {
return $this->lastUpdate;
}
public function _lastUpdate(int $value): void {
$this->lastUpdate = $value;
}
public function inError(): bool {
return $this->error;
}
/** @param bool|int $value */
public function _error($value): void {
$this->error = (bool)$value;
}
public function isDefault(): bool {
return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
}
public function nbFeeds(): int {
if ($this->nbFeeds < 0) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$this->nbFeeds = $catDAO->countFeed($this->id());
}
return $this->nbFeeds;
}
/**
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public function nbNotRead(): int {
if ($this->nbNotRead < 0) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$this->nbNotRead = $catDAO->countNotRead($this->id());
}
return $this->nbNotRead;
}
/** @return array<int,mixed> */
public function curlOptions(): array {
return []; // TODO (e.g., credentials for Dynamic OPML)
}
/**
* @return array<int,FreshRSS_Feed>
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public function feeds(): array {
if ($this->feeds === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->feeds = $feedDAO->listByCategory($this->id());
$this->nbFeeds = 0;
$this->nbNotRead = 0;
foreach ($this->feeds as $feed) {
$this->nbFeeds++;
$this->nbNotRead += $feed->nbNotRead();
$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
}
$this->sortFeeds();
}
return $this->feeds ?? [];
}
public function hasFeedsWithError(): bool {
return (bool)($this->hasFeedsWithError);
}
public function _id(int $id): void {
$this->id = $id;
if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
$this->name = _t('gen.short.default_category');
}
}
public function _kind(int $kind): void {
$this->kind = $kind;
}
public function _name(string $value): void {
if ($this->id !== FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
$this->name = mb_strcut(trim($value), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
}
}
/** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
public function _feeds($values): void {
if (!is_array($values)) {
$values = [$values];
}
$this->feeds = $values;
$this->sortFeeds();
}
/**
* To manually add feeds to this category (not committing to database).
*/
public function addFeed(FreshRSS_Feed $feed): void {
if ($this->feeds === null) {
$this->feeds = [];
}
$feed->_category($this);
$this->feeds[] = $feed;
$this->sortFeeds();
}
/**
* @throws FreshRSS_Context_Exception
*/
public function cacheFilename(string $url): string {
$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
$filename = $simplePie->get_cache_filename($url);
return CACHE_PATH . '/' . $filename . '.opml.xml';
}
public function refreshDynamicOpml(): bool {
$url = $this->attributeString('opml_url');
if ($url == null) {
return false;
}
$ok = true;
$cachePath = $this->cacheFilename($url);
$opml = httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions());
if ($opml == '') {
Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
SimplePie_Misc::url_remove_credentials($url));
$ok = false;
} else {
$dryRunCategory = new FreshRSS_Category();
$importService = new FreshRSS_Import_Service();
$importService->importOpml($opml, $dryRunCategory, true);
if ($importService->lastStatus()) {
$feedDAO = FreshRSS_Factory::createFeedDao();
/** @var array<string,FreshRSS_Feed> */
$dryRunFeeds = [];
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
$dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
}
/** @var array<string,FreshRSS_Feed> */
$existingFeeds = [];
foreach ($this->feeds() as $existingFeed) {
$existingFeeds[$existingFeed->url()] = $existingFeed;
if (empty($dryRunFeeds[$existingFeed->url()])) {
// The feed does not exist in the new dynamic OPML, so mute (disable) that feed
$existingFeed->_mute(true);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
if (empty($existingFeeds[$dryRunFeed->url()])) {
// The feed does not exist in the current category, so add that feed
$dryRunFeed->_category($this);
$ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false);
$existingFeeds[$dryRunFeed->url()] = $dryRunFeed;
} else {
$existingFeed = $existingFeeds[$dryRunFeed->url()];
if ($existingFeed->mute()) {
// The feed already exists in the current category but was muted (disabled), so unmute (enable) again
$existingFeed->_mute(false);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
}
} else {
$ok = false;
Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
SimplePie_Misc::url_remove_credentials($url));
}
}
$catDAO = FreshRSS_Factory::createCategoryDao();
$catDAO->updateLastUpdate($this->id(), !$ok);
return (bool)$ok;
}
private function sortFeeds(): void {
if ($this->feeds === null) {
return;
}
uasort($this->feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
return strnatcasecmp($a->name(), $b->name());
});
}
/**
* Access cached feed
* @param array<FreshRSS_Category> $categories
*/
public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
if ($feed->id() === $feed_id) {
$feed->_category($category); // Should already be done; just to be safe
return $feed;
}
}
}
return null;
}
/**
* Access cached feeds
* @param array<FreshRSS_Category> $categories
* @return array<int,FreshRSS_Feed>
*/
public static function findFeeds(array $categories): array {
$result = [];
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
$result[$feed->id()] = $feed;
}
}
return $result;
}
/**
* @param array<FreshRSS_Category> $categories
*/
public static function countUnread(array $categories, int $minPriority = 0): int {
$n = 0;
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
if ($feed->priority() >= $minPriority) {
$n += $feed->nbNotRead();
}
}
}
return $n;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/CategoryDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_CategoryDAO extends Minz_ModelPdo {
public const DEFAULTCATEGORYID = 1;
public function resetDefaultCategoryName(): bool {
//FreshRSS 1.15.1
$stm = $this->pdo->prepare('UPDATE `_category` SET name = :name WHERE id = :id');
if ($stm !== false) {
$stm->bindValue(':id', self::DEFAULTCATEGORYID, PDO::PARAM_INT);
$stm->bindValue(':name', 'Uncategorized');
}
return $stm && $stm->execute();
}
protected function addColumn(string $name): bool {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
Minz_Log::warning(__method__ . ': ' . $name);
try {
if ($name === 'kind') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
} elseif ($name === 'lastUpdate') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN `lastUpdate` BIGINT DEFAULT 0') !== false;
} elseif ($name === 'error') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN error SMALLINT DEFAULT 0') !== false;
} elseif ('attributes' === $name) { //v1.15.0
$ok = $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN attributes TEXT') !== false;
/** @var array<array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
* 'priority':int,'pathEntries':string,'httpAuth':string,'error':int,'keep_history':?int,'ttl':int,'attributes':string}> $feeds */
$feeds = $this->fetchAssoc('SELECT * FROM `_feed`') ?? [];
$stm = $this->pdo->prepare('UPDATE `_feed` SET attributes = :attributes WHERE id = :id');
if ($stm === false) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
return false;
}
foreach ($feeds as $feed) {
if (empty($feed['keep_history']) || empty($feed['id'])) {
continue;
}
$keepHistory = $feed['keep_history'];
$attributes = empty($feed['attributes']) ? [] : json_decode($feed['attributes'], true);
if (is_string($attributes)) { //Legacy risk of double-encoding
$attributes = json_decode($attributes, true);
}
if (!is_array($attributes)) {
$attributes = [];
}
if ($keepHistory > 0) {
$attributes['archiving']['keep_min'] = (int)$keepHistory;
} elseif ($keepHistory == -1) { //Infinite
$attributes['archiving']['keep_period'] = false;
$attributes['archiving']['keep_max'] = false;
$attributes['archiving']['keep_min'] = false;
} else {
continue;
}
if (!($stm->bindValue(':id', $feed['id'], PDO::PARAM_INT) &&
$stm->bindValue(':attributes', json_encode($attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) &&
$stm->execute())) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($stm->errorInfo()));
}
}
if ($this->pdo->dbType() !== 'sqlite') { //SQLite does not support DROP COLUMN
$this->pdo->exec('ALTER TABLE `_feed` DROP COLUMN keep_history');
} else {
$this->pdo->exec('DROP INDEX IF EXISTS feed_keep_history_index'); //SQLite at least drop index
}
$this->resetDefaultCategoryName();
return $ok;
}
} catch (Exception $e) {
Minz_Log::error(__method__ . ': ' . $e->getMessage());
}
return false;
}
/** @param array<string|int> $errorInfo */
protected function autoUpdateDb(array $errorInfo): bool {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", (string)$errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
}
}
return false;
}
/**
* @param array{'name':string,'id'?:int,'kind'?:int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string|array<string,mixed>} $valuesTmp
* @return int|false
*/
public function addCategory(array $valuesTmp) {
// TRIM() to provide a type hint as text
// No tag of the same name
$sql = <<<'SQL'
INSERT INTO `_category`(kind, name, attributes)
SELECT * FROM (SELECT ABS(?) AS kind, TRIM(?) AS name, TRIM(?) AS attributes) c2
WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = TRIM(?))
SQL;
$stm = $this->pdo->prepare($sql);
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$values = [
$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
$valuesTmp['name'],
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$valuesTmp['name'],
];
if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
$catId = $this->pdo->lastInsertId('`_category_id_seq`');
return $catId === false ? false : (int)$catId;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->addCategory($valuesTmp);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function addCategoryObject(FreshRSS_Category $category) {
$cat = $this->searchByName($category->name());
if (!$cat) {
$values = [
'kind' => $category->kind(),
'name' => $category->name(),
'attributes' => $category->attributes(),
];
return $this->addCategory($values);
}
return $cat->id();
}
/**
* @param array{'name':string,'kind':int,'attributes'?:array<string,mixed>|mixed|null} $valuesTmp
* @return int|false
*/
public function updateCategory(int $id, array $valuesTmp) {
// No tag of the same name
$sql = <<<'SQL'
UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?)
SQL;
$stm = $this->pdo->prepare($sql);
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
if (empty($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$values = [
$valuesTmp['name'],
$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$id,
$valuesTmp['name'],
];
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->updateCategory($id, $valuesTmp);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
$sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
$values = [
$mtime <= 0 ? time() : $mtime,
$inError ? 1 : 0,
$id,
];
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function deleteCategory(int $id) {
if ($id <= self::DEFAULTCATEGORYID) {
return false;
}
$sql = 'DELETE FROM `_category` WHERE id=:id';
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->bindParam(':id', $id, PDO::PARAM_INT) && $stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return Traversable<array{'id':int,'name':string,'kind':int,'lastUpdate':int,'error':int,'attributes'?:array<string,mixed>}> */
public function selectAll(): Traversable {
$sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`';
$stm = $this->pdo->query($sql);
if ($stm !== false) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
/** @var array{'id':int,'name':string,'kind':int,'lastUpdate':int,'error':int,'attributes'?:array<string,mixed>} $row */
yield $row;
}
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
yield from $this->selectAll();
} else {
Minz_Log::error(__method__ . ' error: ' . json_encode($info));
}
}
}
public function searchById(int $id): ?FreshRSS_Category {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$res = $this->fetchAssoc($sql, ['id' => $id]) ?? [];
/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
$categories = self::daoToCategories($res);
return reset($categories) ?: null;
}
public function searchByName(string $name): ?FreshRSS_Category {
$sql = 'SELECT * FROM `_category` WHERE name=:name';
$res = $this->fetchAssoc($sql, ['name' => $name]) ?? [];
/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
$categories = self::daoToCategories($res);
return reset($categories) ?: null;
}
/** @return array<int,FreshRSS_Category> */
public function listSortedCategories(bool $prePopulateFeeds = true, bool $details = false): array {
$categories = $this->listCategories($prePopulateFeeds, $details);
uasort($categories, static function (FreshRSS_Category $a, FreshRSS_Category $b) {
$aPosition = $a->attributeInt('position');
$bPosition = $b->attributeInt('position');
if ($aPosition === $bPosition) {
return ($a->name() < $b->name()) ? -1 : 1;
} elseif (null === $aPosition) {
return 1;
} elseif (null === $bPosition) {
return -1;
}
return ($aPosition < $bPosition) ? -1 : 1;
});
return $categories;
}
/** @return array<int,FreshRSS_Category> */
public function listCategories(bool $prePopulateFeeds = true, bool $details = false): array {
if ($prePopulateFeeds) {
$sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, '
. ($details ? 'f.* ' : 'f.id, f.name, f.url, f.kind, f.website, f.priority, f.error, f.attributes, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ')
. 'FROM `_category` c '
. 'LEFT OUTER JOIN `_feed` f ON f.category=c.id '
. 'WHERE f.priority >= :priority '
. 'GROUP BY f.id, c_id '
. 'ORDER BY c.name, f.name';
$stm = $this->pdo->prepare($sql);
$values = [ ':priority' => FreshRSS_Feed::PRIORITY_CATEGORY ];
if ($stm !== false && $stm->execute($values)) {
$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
/** @var array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
* 'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'category'?:int,'website'?:string,'priority'?:int,'error'?:int|bool,'attributes'?:string,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $res */
return self::daoToCategoriesPrepopulated($res);
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listCategories($prePopulateFeeds, $details);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return [];
}
} else {
$res = $this->fetchAssoc('SELECT * FROM `_category` ORDER BY name');
/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
return empty($res) ? [] : self::daoToCategories($res);
}
}
/** @return array<int,FreshRSS_Category> */
public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0): array {
$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
. ($limit < 1 ? '' : ' LIMIT ' . $limit);
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
$stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) &&
$stm->execute()) {
return self::daoToCategories($stm->fetchAll(PDO::FETCH_ASSOC));
} else {
$info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listCategoriesOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return [];
}
}
public function getDefault(): ?FreshRSS_Category {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$res = $this->fetchAssoc($sql, [':id' => self::DEFAULTCATEGORYID]) ?? [];
/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
$categories = self::daoToCategories($res);
if (isset($categories[self::DEFAULTCATEGORYID])) {
return $categories[self::DEFAULTCATEGORYID];
} else {
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS database error: Default category not found!' . "\n");
}
Minz_Log::error('FreshRSS database error: Default category not found!');
return null;
}
}
/** @return int|bool */
public function checkDefault() {
$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
if ($def_cat == null) {
$cat = new FreshRSS_Category(_t('gen.short.default_category'), self::DEFAULTCATEGORYID);
$sql = 'INSERT INTO `_category`(id, name) VALUES(?, ?)';
if ($this->pdo->dbType() === 'pgsql') {
//Force call to nextval()
$sql .= " RETURNING nextval('`_category_id_seq`');";
}
$stm = $this->pdo->prepare($sql);
$values = [
$cat->id(),
$cat->name(),
];
if ($stm !== false && $stm->execute($values)) {
$catId = $this->pdo->lastInsertId('`_category_id_seq`');
return $catId === false ? false : (int)$catId;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
return true;
}
public function count(): int {
$sql = 'SELECT COUNT(*) AS count FROM `_category`';
$res = $this->fetchColumn($sql, 0);
return isset($res[0]) ? (int)$res[0] : -1;
}
public function countFeed(int $id): int {
$sql = 'SELECT COUNT(*) AS count FROM `_feed` WHERE category=:id';
$res = $this->fetchColumn($sql, 0, [':id' => $id]);
return isset($res[0]) ? (int)$res[0] : -1;
}
public function countNotRead(int $id): int {
$sql = 'SELECT COUNT(*) AS count FROM `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id WHERE category=:id AND e.is_read=0';
$res = $this->fetchColumn($sql, 0, [':id' => $id]);
return isset($res[0]) ? (int)$res[0] : -1;
}
/**
* @param array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
* 'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'website'?:string,'priority'?:int,
* 'error'?:int|bool,'attributes'?:string,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $listDAO
* @return array<int,FreshRSS_Category>
*/
private static function daoToCategoriesPrepopulated(array $listDAO): array {
$list = [];
$previousLine = [];
$feedsDao = [];
$feedDao = FreshRSS_Factory::createFeedDao();
foreach ($listDAO as $line) {
FreshRSS_DatabaseDAO::pdoInt($line, ['c_id', 'c_kind', 'c_last_update', 'c_error',
'id', 'kind', 'priority', 'error', 'cache_nbEntries', 'cache_nbUnreads', 'ttl']);
if (!empty($previousLine['c_id']) && $line['c_id'] !== $previousLine['c_id']) {
// End of the current category, we add it to the $list
$cat = new FreshRSS_Category(
$previousLine['c_name'],
$previousLine['c_id'],
$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
);
$cat->_kind($previousLine['c_kind']);
$cat->_attributes($previousLine['c_attributes'] ?? '[]');
$list[$cat->id()] = $cat;
$feedsDao = []; //Prepare for next category
}
$previousLine = $line;
$feedsDao[] = $line;
}
// add the last category
if ($previousLine != null) {
$cat = new FreshRSS_Category(
$previousLine['c_name'],
$previousLine['c_id'],
$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
);
$cat->_kind($previousLine['c_kind']);
$cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
$cat->_error($previousLine['c_error'] ?? 0);
$cat->_attributes($previousLine['c_attributes'] ?? []);
$list[$cat->id()] = $cat;
}
return $list;
}
/**
* @param array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $listDAO
* @return array<int,FreshRSS_Category>
*/
private static function daoToCategories(array $listDAO): array {
$list = [];
foreach ($listDAO as $dao) {
FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'lastUpdate', 'error']);
$cat = new FreshRSS_Category(
$dao['name'],
$dao['id']
);
$cat->_kind($dao['kind']);
$cat->_lastUpdate($dao['lastUpdate'] ?? 0);
$cat->_error($dao['error'] ?? 0);
$cat->_attributes($dao['attributes'] ?? '');
$list[$cat->id()] = $cat;
}
return $list;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/CategoryDAOSQLite.php'
<?php
declare(strict_types=1);
class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO {
/** @param array<int|string> $errorInfo */
#[\Override]
protected function autoUpdateDb(array $errorInfo): bool {
if ($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) {
$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
if (!in_array($column, $columns, true)) {
return $this->addColumn($column);
}
}
}
return false;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Context.php'
<?php
declare(strict_types=1);
/**
* The context object handles the current configuration file and different
* useful functions associated to the current view state.
*/
final class FreshRSS_Context {
/**
* @var array<int,FreshRSS_Category>
*/
private static array $categories = [];
/**
* @var array<int,FreshRSS_Tag>
*/
private static array $tags = [];
public static string $name = '';
public static string $description = '';
public static int $total_unread = 0;
public static int $total_important_unread = 0;
/** @var array{'all':int,'read':int,'unread':int} */
public static array $total_starred = [
'all' => 0,
'read' => 0,
'unread' => 0,
];
public static int $get_unread = 0;
/** @var array{'all':bool,'starred':bool,'important':bool,'feed':int|false,'category':int|false,'tag':int|false,'tags':bool} */
public static array $current_get = [
'all' => false,
'starred' => false,
'important' => false,
'feed' => false,
'category' => false,
'tag' => false,
'tags' => false,
];
public static string $next_get = 'a';
public static int $state = 0;
/**
* @phpstan-var 'ASC'|'DESC'
*/
public static string $order = 'DESC';
public static int $number = 0;
public static int $offset = 0;
public static FreshRSS_BooleanSearch $search;
public static string $first_id = '';
public static string $next_id = '';
public static string $id_max = '';
public static int $sinceHours = 0;
public static bool $isCli = false;
/**
* @deprecated Will be made `private`; use `FreshRSS_Context::systemConf()` instead.
* @internal
*/
public static ?FreshRSS_SystemConfiguration $system_conf = null;
/**
* @deprecated Will be made `private`; use `FreshRSS_Context::userConf()` instead.
* @internal
*/
public static ?FreshRSS_UserConfiguration $user_conf = null;
/**
* Initialize the context for the global system.
*/
public static function initSystem(bool $reload = false): void {
if ($reload || FreshRSS_Context::$system_conf === null) {
//TODO: Keep in session what we need instead of always reloading from disk
FreshRSS_Context::$system_conf = FreshRSS_SystemConfiguration::init(DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php');
}
}
/**
* @throws FreshRSS_Context_Exception
*/
public static function &systemConf(): FreshRSS_SystemConfiguration {
if (FreshRSS_Context::$system_conf === null) {
throw new FreshRSS_Context_Exception('System configuration not initialised!');
}
return FreshRSS_Context::$system_conf;
}
public static function hasSystemConf(): bool {
return FreshRSS_Context::$system_conf !== null;
}
/**
* Initialize the context for the current user.
*/
public static function initUser(string $username = '', bool $userMustExist = true): void {
FreshRSS_Context::$user_conf = null;
if (!isset($_SESSION)) {
Minz_Session::init('FreshRSS');
}
Minz_Session::lock();
if ($username == '') {
$username = Minz_User::name() ?? '';
}
if (($username === Minz_User::INTERNAL_USER || FreshRSS_user_Controller::checkUsername($username)) &&
(!$userMustExist || FreshRSS_user_Controller::userExists($username))) {
try {
//TODO: Keep in session what we need instead of always reloading from disk
FreshRSS_Context::$user_conf = FreshRSS_UserConfiguration::init(
USERS_PATH . '/' . $username . '/config.php',
FRESHRSS_PATH . '/config-user.default.php');
Minz_User::change($username);
} catch (Exception $ex) {
Minz_Log::warning($ex->getMessage(), USERS_PATH . '/_/' . LOG_FILENAME);
}
}
if (FreshRSS_Context::$user_conf == null) {
Minz_Session::_params([
'loginOk' => false,
Minz_User::CURRENT_USER => false,
]);
}
Minz_Session::unlock();
if (FreshRSS_Context::$user_conf == null) {
return;
}
FreshRSS_Context::$search = new FreshRSS_BooleanSearch('');
//Legacy
$oldEntries = FreshRSS_Context::$user_conf->param('old_entries', 0);
$oldEntries = is_numeric($oldEntries) ? (int)$oldEntries : 0;
$keepMin = FreshRSS_Context::$user_conf->param('keep_history_default', -5);
$keepMin = is_numeric($keepMin) ? (int)$keepMin : -5;
if ($oldEntries > 0 || $keepMin > -5) { //Freshrss < 1.15
$archiving = FreshRSS_Context::$user_conf->archiving;
$archiving['keep_max'] = false;
if ($oldEntries > 0) {
$archiving['keep_period'] = 'P' . $oldEntries . 'M';
}
if ($keepMin > 0) {
$archiving['keep_min'] = $keepMin;
} elseif ($keepMin == -1) { //Infinite
$archiving['keep_period'] = false;
$archiving['keep_min'] = false;
}
FreshRSS_Context::$user_conf->archiving = $archiving;
}
//Legacy < 1.16.1
if (!in_array(FreshRSS_Context::$user_conf->display_categories, [ 'active', 'remember', 'all', 'none' ], true)) {
FreshRSS_Context::$user_conf->display_categories = FreshRSS_Context::$user_conf->display_categories === true ? 'all' : 'active';
}
}
/**
* @throws FreshRSS_Context_Exception
*/
public static function &userConf(): FreshRSS_UserConfiguration {
if (FreshRSS_Context::$user_conf === null) {
throw new FreshRSS_Context_Exception('User configuration not initialised!');
}
return FreshRSS_Context::$user_conf;
}
public static function hasUserConf(): bool {
return FreshRSS_Context::$user_conf !== null;
}
public static function clearUserConf(): void {
FreshRSS_Context::$user_conf = null;
}
/** @return array<int,FreshRSS_Category> */
public static function categories(): array {
if (empty(self::$categories)) {
$catDAO = FreshRSS_Factory::createCategoryDao();
self::$categories = $catDAO->listSortedCategories(true, false);
}
return self::$categories;
}
/** @return array<int,FreshRSS_Feed> */
public static function feeds(): array {
return FreshRSS_Category::findFeeds(self::categories());
}
/** @return array<int,FreshRSS_Tag> */
public static function labels(bool $precounts = false): array {
if (empty(self::$tags) || $precounts) {
$tagDAO = FreshRSS_Factory::createTagDao();
self::$tags = $tagDAO->listTags($precounts) ?: [];
}
return self::$tags;
}
/**
* This action updates the Context object by using request parameters.
*
* HTTP GET request parameters are:
* - state (default: conf->default_view)
* - search (default: empty string)
* - order (default: conf->sort_order)
* - nb (default: conf->posts_per_page)
* - next (default: empty string)
* - hours (default: 0)
* @throws FreshRSS_Context_Exception
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public static function updateUsingRequest(bool $computeStatistics): void {
if ($computeStatistics && self::$total_unread === 0) {
// Update number of read / unread variables.
$entryDAO = FreshRSS_Factory::createEntryDao();
self::$total_starred = $entryDAO->countUnreadReadFavorites();
self::$total_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_MAIN_STREAM);
self::$total_important_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_IMPORTANT);
}
self::_get(Minz_Request::paramString('get') ?: 'a');
self::$state = Minz_Request::paramInt('state') ?: FreshRSS_Context::userConf()->default_state;
$state_forced_by_user = Minz_Request::paramString('state') !== '';
if (!$state_forced_by_user && !self::isStateEnabled(FreshRSS_Entry::STATE_READ)) {
if (FreshRSS_Context::userConf()->default_view === 'all') {
self::$state |= FreshRSS_Entry::STATE_ALL;
} elseif (FreshRSS_Context::userConf()->default_view === 'adaptive' && self::$get_unread <= 0) {
self::$state |= FreshRSS_Entry::STATE_READ;
}
if (FreshRSS_Context::userConf()->show_fav_unread &&
(self::isCurrentGet('s') || self::isCurrentGet('T') || self::isTag())) {
self::$state |= FreshRSS_Entry::STATE_READ;
}
}
self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
$order = Minz_Request::paramString('order') ?: FreshRSS_Context::userConf()->sort_order;
self::$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
self::$number = Minz_Request::paramInt('nb') ?: FreshRSS_Context::userConf()->posts_per_page;
if (self::$number > FreshRSS_Context::userConf()->max_posts_per_rss) {
self::$number = max(
FreshRSS_Context::userConf()->max_posts_per_rss,
FreshRSS_Context::userConf()->posts_per_page);
}
self::$offset = Minz_Request::paramInt('offset');
self::$first_id = Minz_Request::paramString('next');
self::$sinceHours = Minz_Request::paramInt('hours');
}
/**
* Returns if the current state includes $state parameter.
*/
public static function isStateEnabled(int $state): int {
return self::$state & $state;
}
/**
* Returns the current state with or without $state parameter.
*/
public static function getRevertState(int $state): int {
if (self::$state & $state) {
return self::$state & ~$state;
}
return self::$state | $state;
}
/**
* Return the current get as a string or an array.
*
* If $array is true, the first item of the returned value is 'f' or 'c' or 't' and the second is the id.
* @phpstan-return ($asArray is true ? array{'a'|'c'|'f'|'i'|'s'|'t'|'T',bool|int} : string)
* @return string|array{string,bool|int}
*/
public static function currentGet(bool $asArray = false) {
if (self::$current_get['all']) {
return $asArray ? ['a', true] : 'a';
} elseif (self::$current_get['important']) {
return $asArray ? ['i', true] : 'i';
} elseif (self::$current_get['starred']) {
return $asArray ? ['s', true] : 's';
} elseif (self::$current_get['feed']) {
if ($asArray) {
return ['f', self::$current_get['feed']];
} else {
return 'f_' . self::$current_get['feed'];
}
} elseif (self::$current_get['category']) {
if ($asArray) {
return ['c', self::$current_get['category']];
} else {
return 'c_' . self::$current_get['category'];
}
} elseif (self::$current_get['tag']) {
if ($asArray) {
return ['t', self::$current_get['tag']];
} else {
return 't_' . self::$current_get['tag'];
}
} elseif (self::$current_get['tags']) {
return $asArray ? ['T', true] : 'T';
}
return '';
}
/**
* @return bool true if the current request targets all feeds (main view), false otherwise.
*/
public static function isAll(): bool {
return self::$current_get['all'] != false;
}
/**
* @return bool true if the current request targets important feeds, false otherwise.
*/
public static function isImportant(): bool {
return self::$current_get['important'] != false;
}
/**
* @return bool true if the current request targets a category, false otherwise.
*/
public static function isCategory(): bool {
return self::$current_get['category'] != false;
}
/**
* @return bool true if the current request targets a feed (and not a category or all articles), false otherwise.
*/
public static function isFeed(): bool {
return self::$current_get['feed'] != false;
}
/**
* @return bool true if the current request targets a tag (though not all tags), false otherwise.
*/
public static function isTag(): bool {
return self::$current_get['tag'] != false;
}
/**
* @return bool whether $get parameter corresponds to the $current_get attribute.
*/
public static function isCurrentGet(string $get): bool {
$type = substr($get, 0, 1);
$id = substr($get, 2);
switch ($type) {
case 'a':
return self::$current_get['all'];
case 'i':
return self::$current_get['important'];
case 's':
return self::$current_get['starred'];
case 'f':
return self::$current_get['feed'] == $id;
case 'c':
return self::$current_get['category'] == $id;
case 't':
return self::$current_get['tag'] == $id;
case 'T':
return self::$current_get['tags'] || self::$current_get['tag'];
default:
return false;
}
}
/**
* Set the current $get attribute.
*
* Valid $get parameter are:
* - a
* - s
* - f_<feed id>
* - c_<category id>
* - t_<tag id>
*
* $name and $get_unread attributes are also updated as $next_get
* Raise an exception if id or $get is invalid.
* @throws FreshRSS_Context_Exception
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public static function _get(string $get): void {
$type = $get[0];
$id = (int)substr($get, 2);
if (empty(self::$categories)) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$details = $type === 'f'; // Load additional feed details in the case of feed view
self::$categories = $catDAO->listCategories(true, $details);
}
switch ($type) {
case 'a':
self::$current_get['all'] = true;
self::$name = _t('index.feed.title');
self::$description = FreshRSS_Context::systemConf()->meta_description;
self::$get_unread = self::$total_unread;
break;
case 'i':
self::$current_get['important'] = true;
self::$name = _t('index.menu.important');
self::$description = FreshRSS_Context::systemConf()->meta_description;
self::$get_unread = self::$total_unread;
break;
case 's':
self::$current_get['starred'] = true;
self::$name = _t('index.feed.title_fav');
self::$description = FreshRSS_Context::systemConf()->meta_description;
self::$get_unread = self::$total_starred['unread'];
// Update state if favorite is not yet enabled.
self::$state = self::$state | FreshRSS_Entry::STATE_FAVORITE;
break;
case 'f':
// We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description
$feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_Category::findFeed(self::$categories, $id);
if ($feed === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$feed = $feedDAO->searchById($id);
if ($feed === null) {
throw new FreshRSS_Context_Exception('Invalid feed: ' . $id);
}
}
self::$current_get['feed'] = $id;
self::$current_get['category'] = $feed->categoryId();
self::$name = $feed->name();
self::$description = $feed->description();
self::$get_unread = $feed->nbNotRead();
break;
case 'c':
// We try to find the corresponding category.
self::$current_get['category'] = $id;
if (!isset(self::$categories[$id])) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$cat = $catDAO->searchById($id);
if ($cat === null) {
throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
}
self::$categories[$id] = $cat;
} else {
$cat = self::$categories[$id];
}
self::$name = $cat->name();
self::$get_unread = $cat->nbNotRead();
break;
case 't':
// We try to find the corresponding tag.
self::$current_get['tag'] = $id;
if (!isset(self::$tags[$id])) {
$tagDAO = FreshRSS_Factory::createTagDao();
$tag = $tagDAO->searchById($id);
if ($tag === null) {
throw new FreshRSS_Context_Exception('Invalid tag: ' . $id);
}
self::$tags[$id] = $tag;
} else {
$tag = self::$tags[$id];
}
self::$name = $tag->name();
self::$get_unread = $tag->nbUnread();
break;
case 'T':
$tagDAO = FreshRSS_Factory::createTagDao();
self::$current_get['tags'] = true;
self::$name = _t('index.menu.tags');
self::$get_unread = $tagDAO->countNotRead();
break;
default:
throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
}
self::_nextGet();
}
/**
* Set the value of $next_get attribute.
*/
private static function _nextGet(): void {
$get = self::currentGet();
// By default, $next_get == $get
self::$next_get = $get;
if (empty(self::$categories)) {
$catDAO = FreshRSS_Factory::createCategoryDao();
self::$categories = $catDAO->listCategories(true);
}
if (FreshRSS_Context::userConf()->onread_jump_next && strlen($get) > 2) {
$another_unread_id = '';
$found_current_get = false;
switch ($get[0]) {
case 'f':
// We search the next unread feed with the following priorities: next in same category, or previous in same category, or next, or previous.
foreach (self::$categories as $cat) {
$sameCat = false;
foreach ($cat->feeds() as $feed) {
if ($found_current_get) {
if ($feed->nbNotRead() > 0) {
$another_unread_id = $feed->id();
break 2;
}
} elseif ($feed->id() == self::$current_get['feed']) {
$found_current_get = true;
} elseif ($feed->nbNotRead() > 0) {
$another_unread_id = $feed->id();
$sameCat = true;
}
}
if ($found_current_get && $sameCat) {
break;
}
}
// If there is no more unread feed, show main stream
self::$next_get = $another_unread_id == '' ? 'a' : 'f_' . $another_unread_id;
break;
case 'c':
// We search the next category with at least one unread article.
foreach (self::$categories as $cat) {
if ($cat->id() == self::$current_get['category']) {
// Here is our current category! Next one could be our
// champion if it has unread articles.
$found_current_get = true;
continue;
}
if ($cat->nbNotRead() > 0) {
$another_unread_id = $cat->id();
if ($found_current_get) {
// Unread articles and the current category has
// already been found? Leave the loop!
break;
}
}
}
// If there is no more unread category, show main stream
self::$next_get = $another_unread_id == '' ? 'a' : 'c_' . $another_unread_id;
break;
}
}
}
/**
* Determine if the auto remove is available in the current context.
* This feature is available if:
* - it is activated in the configuration
* - the "read" state is not enable
* - the "unread" state is enable
*/
public static function isAutoRemoveAvailable(): bool {
if (!FreshRSS_Context::userConf()->auto_remove_article) {
return false;
}
if (self::isStateEnabled(FreshRSS_Entry::STATE_READ)) {
return false;
}
if (!self::isStateEnabled(FreshRSS_Entry::STATE_NOT_READ)) {
return false;
}
return true;
}
/**
* Determine if the "sticky post" option is enabled. It can be enable
* by the user when it is selected in the configuration page or by the
* application when the context allows to auto-remove articles when they
* are read.
*/
public static function isStickyPostEnabled(): bool {
if (FreshRSS_Context::userConf()->sticky_post) {
return true;
}
if (self::isAutoRemoveAvailable()) {
return true;
}
return false;
}
public static function defaultTimeZone(): string {
$timezone = ini_get('date.timezone');
return $timezone != false ? $timezone : 'UTC';
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/DatabaseDAO.php'
<?php
declare(strict_types=1);
/**
* This class is used to test database is well-constructed.
*/
class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
//MySQL error codes
public const ER_BAD_FIELD_ERROR = '42S22';
public const ER_BAD_TABLE_ERROR = '42S02';
public const ER_DATA_TOO_LONG = '1406';
/**
* Based on SQLite SQLITE_MAX_VARIABLE_NUMBER
*/
public const MAX_VARIABLE_NUMBER = 998;
//MySQL InnoDB maximum index length for UTF8MB4
//https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions.html
public const LENGTH_INDEX_UNICODE = 191;
public function create(): string {
require_once(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
$db = FreshRSS_Context::systemConf()->db;
try {
$sql = sprintf($GLOBALS['SQL_CREATE_DB'], empty($db['base']) ? '' : $db['base']);
return $this->pdo->exec($sql) === false ? 'Error during CREATE DATABASE' : '';
} catch (Exception $e) {
syslog(LOG_DEBUG, __method__ . ' notice: ' . $e->getMessage());
return $e->getMessage();
}
}
public function testConnection(): string {
try {
$sql = 'SELECT 1';
$stm = $this->pdo->query($sql);
if ($stm === false) {
return 'Error during SQL connection test!';
}
$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
return $res == false ? 'Error during SQL connection fetch test!' : '';
} catch (Exception $e) {
syslog(LOG_DEBUG, __method__ . ' warning: ' . $e->getMessage());
return $e->getMessage();
}
}
public function exits(): bool {
$sql = 'SELECT * FROM `_entry` LIMIT 1';
$stm = $this->pdo->query($sql);
if ($stm !== false) {
$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
if ($res !== false) {
return true;
}
}
return false;
}
public function tablesAreCorrect(): bool {
$res = $this->fetchAssoc('SHOW TABLES');
if ($res == null) {
return false;
}
$tables = [
$this->pdo->prefix() . 'category' => false,
$this->pdo->prefix() . 'feed' => false,
$this->pdo->prefix() . 'entry' => false,
$this->pdo->prefix() . 'entrytmp' => false,
$this->pdo->prefix() . 'tag' => false,
$this->pdo->prefix() . 'entrytag' => false,
];
foreach ($res as $value) {
$tables[array_pop($value)] = true;
}
return count(array_keys($tables, true, true)) === count($tables);
}
/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
public function getSchema(string $table): array {
$res = $this->fetchAssoc('DESC `_' . $table . '`');
return $res == null ? [] : $this->listDaoToSchema($res);
}
/** @param array<string> $schema */
public function checkTable(string $table, array $schema): bool {
$columns = $this->getSchema($table);
if (count($columns) === 0 || count($schema) === 0) {
return false;
}
$ok = count($columns) === count($schema);
foreach ($columns as $c) {
$ok &= in_array($c['name'], $schema, true);
}
return (bool)$ok;
}
public function categoryIsCorrect(): bool {
return $this->checkTable('category', ['id', 'name']);
}
public function feedIsCorrect(): bool {
return $this->checkTable('feed', [
'id',
'url',
'category',
'name',
'website',
'description',
'lastUpdate',
'priority',
'pathEntries',
'httpAuth',
'error',
'ttl',
'attributes',
'cache_nbEntries',
'cache_nbUnreads',
]);
}
public function entryIsCorrect(): bool {
return $this->checkTable('entry', [
'id',
'guid',
'title',
'author',
'content_bin',
'link',
'date',
'lastSeen',
'hash',
'is_read',
'is_favorite',
'id_feed',
'tags',
]);
}
public function entrytmpIsCorrect(): bool {
return $this->checkTable('entrytmp', [
'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags'
]);
}
public function tagIsCorrect(): bool {
return $this->checkTable('tag', ['id', 'name', 'attributes']);
}
public function entrytagIsCorrect(): bool {
return $this->checkTable('entrytag', ['id_tag', 'id_entry']);
}
/**
* @param array<string,string|int|bool|null> $dao
* @return array{name:string,type:string,notnull:bool,default:mixed}
*/
public function daoToSchema(array $dao): array {
return [
'name' => (string)($dao['Field']),
'type' => strtolower((string)($dao['Type'])),
'notnull' => (bool)$dao['Null'],
'default' => $dao['Default'],
];
}
/**
* @param array<array<string,string|int|bool|null>> $listDAO
* @return array<array{name:string,type:string,notnull:bool,default:mixed}>
*/
public function listDaoToSchema(array $listDAO): array {
$list = [];
foreach ($listDAO as $dao) {
$list[] = $this->daoToSchema($dao);
}
return $list;
}
public function size(bool $all = false): int {
$db = FreshRSS_Context::systemConf()->db;
// MariaDB does not refresh size information automatically
$sql = <<<'SQL'
ANALYZE TABLE `_category`, `_feed`, `_entry`, `_entrytmp`, `_tag`, `_entrytag`
SQL;
$stm = $this->pdo->query($sql);
if ($stm !== false) {
$stm->fetchAll();
}
//MySQL:
$sql = <<<'SQL'
SELECT SUM(DATA_LENGTH + INDEX_LENGTH + DATA_FREE)
FROM information_schema.TABLES WHERE TABLE_SCHEMA=:table_schema
SQL;
$values = [':table_schema' => $db['base']];
if (!$all) {
$sql .= ' AND table_name LIKE :table_name';
$values[':table_name'] = $this->pdo->prefix() . '%';
}
$res = $this->fetchColumn($sql, 0, $values);
return isset($res[0]) ? (int)($res[0]) : -1;
}
public function optimize(): bool {
$ok = true;
$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];
foreach ($tables as $table) {
$sql = 'OPTIMIZE TABLE `_' . $table . '`'; //MySQL
$stm = $this->pdo->query($sql);
if ($stm == false || $stm->fetchAll(PDO::FETCH_ASSOC) == false) {
$ok = false;
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
}
}
return $ok;
}
public function minorDbMaintenance(): void {
$catDAO = FreshRSS_Factory::createCategoryDao();
$catDAO->resetDefaultCategoryName();
include_once(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
if (!empty($GLOBALS['SQL_UPDATE_MINOR'])) {
$sql = $GLOBALS['SQL_UPDATE_MINOR'];
$isMariaDB = false;
if ($this->pdo->dbType() === 'mysql') {
$dbVersion = $this->fetchValue('SELECT version()') ?? '';
$isMariaDB = stripos($dbVersion, 'MariaDB') !== false; // MariaDB includes its name in version, but not MySQL
if (!$isMariaDB) {
// MySQL does not support `DROP INDEX IF EXISTS` yet https://dev.mysql.com/doc/refman/8.3/en/drop-index.html
// but MariaDB does https://mariadb.com/kb/en/drop-index/
$sql = str_replace('DROP INDEX IF EXISTS', 'DROP INDEX', $sql);
}
}
if ($this->pdo->exec($sql) === false) {
$info = $this->pdo->errorInfo();
if ($this->pdo->dbType() === 'mysql' &&
!$isMariaDB && !empty($info[2]) && (stripos($info[2], "Can't DROP ") !== false)) {
// Too bad for MySQL, but ignore error
return;
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
}
}
}
private static function stdError(string $error): bool {
if (defined('STDERR')) {
fwrite(STDERR, $error . "\n");
}
Minz_Log::error($error);
return false;
}
public const SQLITE_EXPORT = 1;
public const SQLITE_IMPORT = 2;
public function dbCopy(string $filename, int $mode, bool $clearFirst = false, bool $verbose = true): bool {
if (!extension_loaded('pdo_sqlite')) {
return self::stdError('PHP extension pdo_sqlite is missing!');
}
$error = '';
$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
$userDAO = FreshRSS_Factory::createUserDao();
$catDAO = FreshRSS_Factory::createCategoryDao();
$feedDAO = FreshRSS_Factory::createFeedDao();
$entryDAO = FreshRSS_Factory::createEntryDao();
$tagDAO = FreshRSS_Factory::createTagDao();
switch ($mode) {
case self::SQLITE_EXPORT:
if (@filesize($filename) > 0) {
$error = 'Error: SQLite export file already exists: ' . $filename;
}
break;
case self::SQLITE_IMPORT:
if (!is_readable($filename)) {
$error = 'Error: SQLite import file is not readable: ' . $filename;
} elseif ($clearFirst) {
$userDAO->deleteUser();
$userDAO = FreshRSS_Factory::createUserDao();
if ($this->pdo->dbType() === 'sqlite') {
//We cannot just delete the .sqlite file otherwise PDO gets buggy.
//SQLite is the only one with database-level optimization, instead of at table level.
$this->optimize();
}
} else {
if ($databaseDAO->exits()) {
$nbEntries = $entryDAO->countUnreadRead();
if (isset($nbEntries['all']) && $nbEntries['all'] > 0) {
$error = 'Error: Destination database already contains some entries!';
}
}
}
break;
default:
$error = 'Invalid copy mode!';
break;
}
if ($error != '') {
return self::stdError($error);
}
$sqlite = null;
try {
$sqlite = new Minz_PdoSqlite('sqlite:' . $filename);
$sqlite->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
} catch (Exception $e) {
$error = 'Error while initialising SQLite copy: ' . $e->getMessage();
return self::stdError($error);
}
Minz_ModelPdo::clean();
$userDAOSQLite = new FreshRSS_UserDAO('', $sqlite);
$categoryDAOSQLite = new FreshRSS_CategoryDAOSQLite('', $sqlite);
$feedDAOSQLite = new FreshRSS_FeedDAOSQLite('', $sqlite);
$entryDAOSQLite = new FreshRSS_EntryDAOSQLite('', $sqlite);
$tagDAOSQLite = new FreshRSS_TagDAOSQLite('', $sqlite);
switch ($mode) {
case self::SQLITE_EXPORT:
$userFrom = $userDAO; $userTo = $userDAOSQLite;
$catFrom = $catDAO; $catTo = $categoryDAOSQLite;
$feedFrom = $feedDAO; $feedTo = $feedDAOSQLite;
$entryFrom = $entryDAO; $entryTo = $entryDAOSQLite;
$tagFrom = $tagDAO; $tagTo = $tagDAOSQLite;
break;
case self::SQLITE_IMPORT:
$userFrom = $userDAOSQLite; $userTo = $userDAO;
$catFrom = $categoryDAOSQLite; $catTo = $catDAO;
$feedFrom = $feedDAOSQLite; $feedTo = $feedDAO;
$entryFrom = $entryDAOSQLite; $entryTo = $entryDAO;
$tagFrom = $tagDAOSQLite; $tagTo = $tagDAO;
break;
default:
return false;
}
$idMaps = [];
if (defined('STDERR') && $verbose) {
fwrite(STDERR, "Start SQL copyβ¦\n");
}
$userTo->createUser();
$catTo->beginTransaction();
foreach ($catFrom->selectAll() as $category) {
$cat = $catTo->searchByName($category['name']); //Useful for the default category
if ($cat != null) {
$catId = $cat->id();
} else {
$catId = $catTo->addCategory($category);
if ($catId == false) {
$error = 'Error during SQLite copy of categories!';
return self::stdError($error);
}
}
$idMaps['c' . $category['id']] = $catId;
}
foreach ($feedFrom->selectAll() as $feed) {
$feed['category'] = empty($idMaps['c' . $feed['category']]) ? FreshRSS_CategoryDAO::DEFAULTCATEGORYID : $idMaps['c' . $feed['category']];
$feedId = $feedTo->addFeed($feed);
if ($feedId == false) {
$error = 'Error during SQLite copy of feeds!';
return self::stdError($error);
}
$idMaps['f' . $feed['id']] = $feedId;
}
$catTo->commit();
$nbEntries = $entryFrom->count();
$n = 0;
$entryTo->beginTransaction();
foreach ($entryFrom->selectAll() as $entry) {
$n++;
if (!empty($idMaps['f' . $entry['id_feed']])) {
$entry['id_feed'] = $idMaps['f' . $entry['id_feed']];
if (!$entryTo->addEntry($entry, false)) {
$error = 'Error during SQLite copy of entries!';
return self::stdError($error);
}
}
if ($n % 100 === 1 && defined('STDERR') && $verbose) { //Display progression
fwrite(STDERR, "\033[0G" . $n . '/' . $nbEntries);
}
}
if (defined('STDERR') && $verbose) {
fwrite(STDERR, "\033[0G" . $n . '/' . $nbEntries . "\n");
}
$entryTo->commit();
$feedTo->updateCachedValues();
$idMaps = [];
$tagTo->beginTransaction();
foreach ($tagFrom->selectAll() as $tag) {
$tagId = $tagTo->addTag($tag);
if ($tagId == false) {
$error = 'Error during SQLite copy of tags!';
return self::stdError($error);
}
$idMaps['t' . $tag['id']] = $tagId;
}
foreach ($tagFrom->selectEntryTag() as $entryTag) {
if (!empty($idMaps['t' . $entryTag['id_tag']])) {
$entryTag['id_tag'] = $idMaps['t' . $entryTag['id_tag']];
if (!$tagTo->tagEntry($entryTag['id_tag'], $entryTag['id_entry'])) {
$error = 'Error during SQLite copy of entry-tags!';
return self::stdError($error);
}
}
}
$tagTo->commit();
return true;
}
/**
* Ensure that some PDO columns are `int` and not `string`.
* Compatibility with PHP 7.
* @param array<string|int|null> $table
* @param array<string> $columns
*/
public static function pdoInt(array &$table, array $columns): void {
foreach ($columns as $column) {
if (isset($table[$column]) && is_string($table[$column])) {
$table[$column] = (int)$table[$column];
}
}
}
/**
* Ensure that some PDO columns are `string` and not `bigint`.
* @param array<string|int|null> $table
* @param array<string> $columns
*/
public static function pdoString(array &$table, array $columns): void {
foreach ($columns as $column) {
if (isset($table[$column])) {
$table[$column] = (string)$table[$column];
}
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/DatabaseDAOPGSQL.php'
<?php
declare(strict_types=1);
/**
* This class is used to test database is well-constructed.
*/
class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
//PostgreSQL error codes
public const UNDEFINED_COLUMN = '42703';
public const UNDEFINED_TABLE = '42P01';
#[\Override]
public function tablesAreCorrect(): bool {
$db = FreshRSS_Context::systemConf()->db;
$sql = 'SELECT * FROM pg_catalog.pg_tables where tableowner=:tableowner';
$res = $this->fetchAssoc($sql, [':tableowner' => $db['user']]);
if ($res == null) {
return false;
}
$tables = [
$this->pdo->prefix() . 'category' => false,
$this->pdo->prefix() . 'feed' => false,
$this->pdo->prefix() . 'entry' => false,
$this->pdo->prefix() . 'entrytmp' => false,
$this->pdo->prefix() . 'tag' => false,
$this->pdo->prefix() . 'entrytag' => false,
];
foreach ($res as $value) {
$tables[array_pop($value)] = true;
}
return count(array_keys($tables, true, true)) === count($tables);
}
/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
#[\Override]
public function getSchema(string $table): array {
$sql = <<<'SQL'
SELECT column_name AS field, data_type AS type, column_default AS default, is_nullable AS null
FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = :table_name
SQL;
$res = $this->fetchAssoc($sql, [':table_name' => $this->pdo->prefix() . $table]);
return $res == null ? [] : $this->listDaoToSchema($res);
}
/**
* @param array<string,string|int|bool|null> $dao
* @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
*/
#[\Override]
public function daoToSchema(array $dao): array {
return [
'name' => (string)($dao['field']),
'type' => strtolower((string)($dao['type'])),
'notnull' => (bool)$dao['null'],
'default' => $dao['default'],
];
}
#[\Override]
public function size(bool $all = false): int {
if ($all) {
$db = FreshRSS_Context::systemConf()->db;
$res = $this->fetchColumn('SELECT pg_database_size(:base)', 0, [':base' => $db['base']]);
} else {
$sql = <<<SQL
SELECT
pg_total_relation_size('`{$this->pdo->prefix()}category`') +
pg_total_relation_size('`{$this->pdo->prefix()}feed`') +
pg_total_relation_size('`{$this->pdo->prefix()}entry`') +
pg_total_relation_size('`{$this->pdo->prefix()}entrytmp`') +
pg_total_relation_size('`{$this->pdo->prefix()}tag`') +
pg_total_relation_size('`{$this->pdo->prefix()}entrytag`')
SQL;
$res = $this->fetchColumn($sql, 0);
}
return (int)($res[0] ?? -1);
}
#[\Override]
public function optimize(): bool {
$ok = true;
$tables = ['category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag'];
foreach ($tables as $table) {
$sql = 'VACUUM `_' . $table . '`';
if ($this->pdo->exec($sql) === false) {
$ok = false;
$info = $this->pdo->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
}
}
return $ok;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/DatabaseDAOSQLite.php'
<?php
declare(strict_types=1);
/**
* This class is used to test database is well-constructed (SQLite).
*/
class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
#[\Override]
public function tablesAreCorrect(): bool {
$sql = "SELECT name FROM sqlite_master WHERE type='table'";
$stm = $this->pdo->query($sql);
$res = $stm ? $stm->fetchAll(PDO::FETCH_ASSOC) : false;
if ($res === false) {
return false;
}
$tables = [
$this->pdo->prefix() . 'category' => false,
$this->pdo->prefix() . 'feed' => false,
$this->pdo->prefix() . 'entry' => false,
$this->pdo->prefix() . 'entrytmp' => false,
$this->pdo->prefix() . 'tag' => false,
$this->pdo->prefix() . 'entrytag' => false,
];
foreach ($res as $value) {
$tables[$value['name']] = true;
}
return count(array_keys($tables, true, true)) == count($tables);
}
/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
#[\Override]
public function getSchema(string $table): array {
$sql = 'PRAGMA table_info(' . $table . ')';
$stm = $this->pdo->query($sql);
return $stm ? $this->listDaoToSchema($stm->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
}
#[\Override]
public function entryIsCorrect(): bool {
return $this->checkTable('entry', [
'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags',
]);
}
#[\Override]
public function entrytmpIsCorrect(): bool {
return $this->checkTable('entrytmp', [
'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read', 'is_favorite', 'id_feed', 'tags'
]);
}
/**
* @param array<string,string|int|bool|null> $dao
* @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
*/
#[\Override]
public function daoToSchema(array $dao): array {
return [
'name' => (string)$dao['name'],
'type' => strtolower((string)$dao['type']),
'notnull' => $dao['notnull'] == '1' ? true : false,
'default' => $dao['dflt_value'],
];
}
#[\Override]
public function size(bool $all = false): int {
$sum = 0;
if ($all) {
foreach (glob(DATA_PATH . '/users/*/db.sqlite') ?: [] as $filename) {
$sum += (@filesize($filename) ?: 0);
}
} else {
$sum = (@filesize(DATA_PATH . '/users/' . $this->current_user . '/db.sqlite') ?: 0);
}
return $sum;
}
#[\Override]
public function optimize(): bool {
$ok = $this->pdo->exec('VACUUM') !== false;
if (!$ok) {
$info = $this->pdo->errorInfo();
Minz_Log::warning(__METHOD__ . ' error : ' . json_encode($info));
}
return $ok;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Days.php'
<?php
declare(strict_types=1);
class FreshRSS_Days {
public const TODAY = 0;
public const YESTERDAY = 1;
public const BEFORE_YESTERDAY = 2;
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Entry.php'
<?php
declare(strict_types=1);
class FreshRSS_Entry extends Minz_Model {
use FreshRSS_AttributesTrait;
public const STATE_READ = 1;
public const STATE_NOT_READ = 2;
public const STATE_ALL = 3;
public const STATE_FAVORITE = 4;
public const STATE_NOT_FAVORITE = 8;
/** @var numeric-string */
private string $id = '0';
private string $guid;
private string $title;
/** @var array<string> */
private array $authors;
private string $content;
private string $link;
private int $date;
private int $lastSeen = 0;
/** In microseconds */
private string $date_added = '0';
private string $hash = '';
private ?bool $is_read;
private ?bool $is_favorite;
private bool $is_updated = false;
private int $feedId;
private ?FreshRSS_Feed $feed;
/** @var array<string> */
private array $tags = [];
/**
* @param int|string $pubdate
* @param bool|int|null $is_read
* @param bool|int|null $is_favorite
* @param string|array<string> $tags
*/
public function __construct(int $feedId = 0, string $guid = '', string $title = '', string $authors = '', string $content = '',
string $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') {
$this->_title($title);
$this->_authors($authors);
$this->_content($content);
$this->_link($link);
$this->_date($pubdate);
$this->_isRead($is_read);
$this->_isFavorite($is_favorite);
$this->_feedId($feedId);
$this->_tags($tags);
$this->_guid($guid);
}
/** @param array{'id'?:string,'id_feed'?:int,'guid'?:string,'title'?:string,'author'?:string,'content'?:string,'link'?:string,'date'?:int|string,'lastSeen'?:int,
* 'hash'?:string,'is_read'?:bool|int,'is_favorite'?:bool|int,'tags'?:string|array<string>,'attributes'?:?string,'thumbnail'?:string,'timestamp'?:string} $dao */
public static function fromArray(array $dao): FreshRSS_Entry {
FreshRSS_DatabaseDAO::pdoInt($dao, ['id_feed', 'date', 'lastSeen', 'is_read', 'is_favorite']);
if (empty($dao['content'])) {
$dao['content'] = '';
}
$dao['attributes'] = empty($dao['attributes']) ? [] : json_decode($dao['attributes'], true);
if (!is_array($dao['attributes'])) {
$dao['attributes'] = [];
}
if (!empty($dao['thumbnail'])) {
$dao['attributes']['thumbnail'] = [
'url' => $dao['thumbnail'],
];
}
$entry = new FreshRSS_Entry(
$dao['id_feed'] ?? 0,
$dao['guid'] ?? '',
$dao['title'] ?? '',
$dao['author'] ?? '',
$dao['content'],
$dao['link'] ?? '',
$dao['date'] ?? 0,
$dao['is_read'] ?? false,
$dao['is_favorite'] ?? false,
$dao['tags'] ?? ''
);
if (!empty($dao['id'])) {
$entry->_id($dao['id']);
}
if (!empty($dao['timestamp'])) {
$entry->_date(strtotime($dao['timestamp']) ?: 0);
}
if (isset($dao['lastSeen'])) {
$entry->_lastSeen($dao['lastSeen']);
}
if (!empty($dao['attributes'])) {
$entry->_attributes($dao['attributes']);
}
if (!empty($dao['hash'])) {
$entry->_hash($dao['hash']);
}
return $entry;
}
/**
* @param Traversable<array{'id'?:string,'id_feed'?:int,'guid'?:string,'title'?:string,'author'?:string,'content'?:string,'link'?:string,'date'?:int|string,'lastSeen'?:int,
* 'hash'?:string,'is_read'?:bool|int,'is_favorite'?:bool|int,'tags'?:string|array<string>,'attributes'?:?string,'thumbnail'?:string,'timestamp'?:string}> $daos
* @return Traversable<FreshRSS_Entry>
*/
public static function fromTraversable(Traversable $daos): Traversable {
foreach ($daos as $dao) {
yield FreshRSS_Entry::fromArray($dao);
}
}
/** @return numeric-string */
public function id(): string {
return $this->id;
}
public function guid(): string {
return $this->guid;
}
public function title(): string {
$title = '';
if ($this->title === '') {
// used while fetching the article from feed and store it in the database
$title = $this->guid();
} else {
// used while fetching from the database
if ($this->title !== $this->guid) {
$title = $this->title;
} else {
$content = trim(strip_tags($this->content(false)));
$title = trim(mb_substr($content, 0, MAX_CHARS_EMPTY_FEED_TITLE, 'UTF-8'));
if ($title === '') {
$title = $this->guid();
} elseif (strlen($content) > strlen($title)) {
$title .= 'β¦';
}
}
}
return $title;
}
/** @deprecated */
public function author(): string {
return $this->authors(true);
}
/**
* @phpstan-return ($asString is true ? string : array<string>)
* @return string|array<string>
*/
public function authors(bool $asString = false) {
if ($asString) {
return $this->authors == null ? '' : ';' . implode('; ', $this->authors);
} else {
return $this->authors;
}
}
/**
* Basic test without ambition to catch all cases such as unquoted addresses, variants of entities, HTML comments, etc.
*/
private static function containsLink(string $html, string $link): bool {
return preg_match('/(?P<delim>[\'"])' . preg_quote($link, '/') . '(?P=delim)/', $html) == 1;
}
/** @param array{'url'?:string,'length'?:int,'medium'?:string,'type'?:string} $enclosure */
private static function enclosureIsImage(array $enclosure): bool {
$elink = $enclosure['url'] ?? '';
$length = $enclosure['length'] ?? 0;
$medium = $enclosure['medium'] ?? '';
$mime = $enclosure['type'] ?? '';
return ($elink != '' && $medium === 'image') || strpos($mime, 'image') === 0 ||
($mime == '' && $length == 0 && preg_match('/[.](avif|gif|jpe?g|png|svg|webp)([?#]|$)/i', $elink));
}
/**
* Provides the original content without additional content potentially added by loadCompleteContent().
*/
public function originalContent(): string {
return preg_replace('#<!-- FULLCONTENT start //-->.*<!-- FULLCONTENT end //-->#s', '', $this->content) ?? '';
}
/**
* @param bool $withEnclosures Set to true to include the enclosures in the returned HTML, false otherwise.
* @param bool $allowDuplicateEnclosures Set to false to remove obvious enclosure duplicates (based on simple string comparison), true otherwise.
* @return string HTML content
*/
public function content(bool $withEnclosures = true, bool $allowDuplicateEnclosures = false): string {
if (!$withEnclosures) {
return $this->content;
}
$content = $this->content;
$thumbnailAttribute = $this->attributeArray('thumbnail') ?? [];
if (!empty($thumbnailAttribute['url'])) {
$elink = $thumbnailAttribute['url'];
if (is_string($elink) && ($allowDuplicateEnclosures || !self::containsLink($content, $elink))) {
$content .= <<<HTML
<figure class="enclosure">
<p class="enclosure-content">
<img class="enclosure-thumbnail" src="{$elink}" alt="" />
</p>
</figure>
HTML;
}
}
$attributeEnclosures = $this->attributeArray('enclosures');
if (empty($attributeEnclosures)) {
return $content;
}
foreach ($attributeEnclosures as $enclosure) {
if (!is_array($enclosure)) {
continue;
}
$elink = $enclosure['url'] ?? '';
if ($elink == '' || !is_string($elink)) {
continue;
}
if (!$allowDuplicateEnclosures && self::containsLink($content, $elink)) {
continue;
}
$credits = $enclosure['credit'] ?? '';
$description = nl2br($enclosure['description'] ?? '', true);
$length = $enclosure['length'] ?? 0;
$medium = $enclosure['medium'] ?? '';
$mime = $enclosure['type'] ?? '';
$thumbnails = $enclosure['thumbnails'] ?? null;
if (!is_array($thumbnails)) {
$thumbnails = [];
}
$etitle = $enclosure['title'] ?? '';
$content .= "\n";
$content .= '<figure class="enclosure">';
foreach ($thumbnails as $thumbnail) {
$content .= '<p><img class="enclosure-thumbnail" src="' . $thumbnail . '" alt="" title="' . $etitle . '" /></p>';
}
if (self::enclosureIsImage($enclosure)) {
$content .= '<p class="enclosure-content"><img src="' . $elink . '" alt="" title="' . $etitle . '" /></p>';
} elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) {
$content .= '<p class="enclosure-content"><audio preload="none" src="' . $elink
. ($length == null ? '' : '" data-length="' . (int)$length)
. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
. '" controls="controls" title="' . $etitle . '"></audio> <a download="" href="' . $elink . '">πΎ</a></p>';
} elseif ($medium === 'video' || strpos($mime, 'video') === 0) {
$content .= '<p class="enclosure-content"><video preload="none" src="' . $elink
. ($length == null ? '' : '" data-length="' . (int)$length)
. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
. '" controls="controls" title="' . $etitle . '"></video> <a download="" href="' . $elink . '">πΎ</a></p>';
} else { //e.g. application, text, unknown
$content .= '<p class="enclosure-content"><a download="" href="' . $elink
. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
. ($medium == '' ? '' : '" data-medium="' . htmlspecialchars($medium, ENT_COMPAT, 'UTF-8'))
. '" title="' . $etitle . '">πΎ</a></p>';
}
if ($credits != '') {
if (!is_array($credits)) {
$credits = [$credits];
}
foreach ($credits as $credit) {
$content .= '<p class="enclosure-credits">Β© ' . $credit . '</p>';
}
}
if ($description != '') {
$content .= '<figcaption class="enclosure-description">' . $description . '</figcaption>';
}
$content .= "</figure>\n";
}
return $content;
}
/** @return Traversable<array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string|array<string>,'height'?:int,'width'?:int,'thumbnails'?:array<string>}> */
public function enclosures(bool $searchBodyImages = false): Traversable {
$attributeEnclosures = $this->attributeArray('enclosures');
if (is_iterable($attributeEnclosures)) {
// FreshRSS 1.20.1+: The enclosures are saved as attributes
/** @var iterable<array{'url':string,'type'?:string,'medium'?:string,'length'?:int,'title'?:string,'description'?:string,'credit'?:string|array<string>,'height'?:int,'width'?:int,'thumbnails'?:array<string>}> $attributeEnclosures */
yield from $attributeEnclosures;
}
try {
$searchEnclosures = !is_iterable($attributeEnclosures) && (strpos($this->content, '<p class="enclosure-content') !== false);
$searchBodyImages &= (stripos($this->content, '<img') !== false);
$xpath = null;
if ($searchEnclosures || $searchBodyImages) {
$dom = new DOMDocument();
$dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $this->content, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
$xpath = new DOMXPath($dom);
}
if ($searchEnclosures && $xpath !== null) {
// Legacy code for database entries < FreshRSS 1.20.1
$enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]');
if (!empty($enclosures)) {
foreach ($enclosures as $enclosure) {
if (!($enclosure instanceof DOMElement)) {
continue;
}
$result = [
'url' => $enclosure->getAttribute('src'),
'type' => $enclosure->getAttribute('data-type'),
'medium' => $enclosure->getAttribute('data-medium'),
'length' => (int)($enclosure->getAttribute('data-length')),
];
if (empty($result['medium'])) {
switch (strtolower($enclosure->nodeName)) {
case 'img': $result['medium'] = 'image'; break;
case 'video': $result['medium'] = 'video'; break;
case 'audio': $result['medium'] = 'audio'; break;
}
}
yield Minz_Helper::htmlspecialchars_utf8($result);
}
}
}
if ($searchBodyImages && $xpath !== null) {
$images = $xpath->query('//img');
if (!empty($images)) {
foreach ($images as $img) {
if (!($img instanceof DOMElement)) {
continue;
}
$src = $img->getAttribute('src');
if ($src == null) {
$src = $img->getAttribute('data-src');
}
if ($src != null) {
$result = [
'url' => $src,
'medium' => 'image',
];
yield Minz_Helper::htmlspecialchars_utf8($result);
}
}
}
}
} catch (Exception $ex) {
Minz_Log::debug(__METHOD__ . ' ' . $ex->getMessage());
}
}
/**
* @return array{'url':string,'height'?:int,'width'?:int,'time'?:string}|null
*/
public function thumbnail(bool $searchEnclosures = true): ?array {
$thumbnail = $this->attributeArray('thumbnail') ?? [];
// First, use the provided thumbnail, if any
if (!empty($thumbnail['url'])) {
/** @var array{'url':string,'height'?:int,'width'?:int,'time'?:string} $thumbnail */
return $thumbnail;
}
if ($searchEnclosures) {
foreach ($this->enclosures(true) as $enclosure) {
// Second, search each enclosureβs thumbnails
if (!empty($enclosure['thumbnails'][0])) {
foreach ($enclosure['thumbnails'] as $src) {
if (is_string($src)) {
return [
'url' => $src,
'medium' => 'image',
];
}
}
}
// Third, check whether each enclosure itself is an appropriate image
if (self::enclosureIsImage($enclosure)) {
return $enclosure;
}
}
}
return null;
}
/** @return string HTML-encoded link of the entry */
public function link(): string {
return $this->link;
}
/**
* @phpstan-return ($raw is false ? string : int)
* @return string|int
*/
public function date(bool $raw = false) {
if ($raw) {
return $this->date;
}
return timestamptodate($this->date);
}
public function machineReadableDate(): string {
return @date(DATE_ATOM, $this->date);
}
public function lastSeen(): int {
return $this->lastSeen;
}
/**
* @phpstan-return ($raw is false ? string : ($microsecond is true ? string : int))
* @return int|string
*/
public function dateAdded(bool $raw = false, bool $microsecond = false) {
if ($raw) {
if ($microsecond) {
return $this->date_added;
} else {
return (int)substr($this->date_added, 0, -6);
}
} else {
$date = (int)substr($this->date_added, 0, -6);
return timestamptodate($date);
}
}
public function isRead(): ?bool {
return $this->is_read;
}
public function isFavorite(): ?bool {
return $this->is_favorite;
}
/**
* Returns whether the entry has been modified since it was inserted in database.
* @returns bool `true` if the entry already existed (and has been modified), `false` if the entry is new (or unmodified).
*/
public function isUpdated(): ?bool {
return $this->is_updated;
}
public function _isUpdated(bool $value): void {
$this->is_updated = $value;
}
public function feed(): ?FreshRSS_Feed {
if ($this->feed === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->feed = $feedDAO->searchById($this->feedId);
}
return $this->feed;
}
public function feedId(): int {
return $this->feedId;
}
/**
* @phpstan-return ($asString is true ? string : array<string>)
* @return string|array<string>
*/
public function tags(bool $asString = false) {
if ($asString) {
return $this->tags == null ? '' : '#' . implode(' #', $this->tags);
} else {
return $this->tags;
}
}
public function hash(): string {
if ($this->hash == '') {
//Do not include $this->date because it may be automatically generated when lacking
$this->hash = md5($this->link . $this->title . $this->authors(true) . $this->originalContent() . $this->tags(true));
}
return $this->hash;
}
public function _hash(string $value): string {
$value = trim($value);
if (ctype_xdigit($value)) {
$this->hash = substr($value, 0, 32);
}
return $this->hash;
}
/** @param int|numeric-string $value String is for compatibility with 32-bit platforms */
public function _id($value): void {
if (is_int($value)) {
$value = (string)$value;
}
$this->id = $value;
if ($this->date_added == 0) {
$this->date_added = $value;
}
}
public function _guid(string $value): void {
$value = trim($value);
if (empty($value)) {
$value = $this->link;
if (empty($value)) {
$value = $this->hash();
}
}
$this->guid = $value;
}
public function _title(string $value): void {
$this->hash = '';
$this->title = trim($value);
}
/** @deprecated */
public function _author(string $value): void {
$this->_authors($value);
}
/** @param array<string>|string $value */
public function _authors($value): void {
$this->hash = '';
if (!is_array($value)) {
if (strpos($value, ';') !== false) {
$value = htmlspecialchars_decode($value, ENT_QUOTES);
$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$value = Minz_Helper::htmlspecialchars_utf8($value);
} else {
$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
}
$this->authors = $value;
}
public function _content(string $value): void {
$this->hash = '';
$this->content = $value;
}
public function _link(string $value): void {
$this->hash = '';
$this->link = trim($value);
}
/** @param int|string $value */
public function _date($value): void {
$value = (int)$value;
$this->date = $value > 1 ? $value : time();
}
public function _lastSeen(int $value): void {
$this->lastSeen = $value > 0 ? $value : 0;
}
/** @param int|string $value */
public function _dateAdded($value, bool $microsecond = false): void {
if ($microsecond) {
$this->date_added = (string)($value);
} else {
$this->date_added = $value . '000000';
}
}
/** @param bool|int|null $value */
public function _isRead($value): void {
$this->is_read = $value === null ? null : (bool)$value;
}
/** @param bool|int|null $value */
public function _isFavorite($value): void {
$this->is_favorite = $value === null ? null : (bool)$value;
}
public function _feed(?FreshRSS_Feed $feed): void {
$this->feed = $feed;
$this->feedId = $this->feed == null ? 0 : $this->feed->id();
}
/** @param int|string $id */
private function _feedId($id): void {
$this->feed = null;
$this->feedId = (int)$id;
}
/** @param array<string>|string $value */
public function _tags($value): void {
$this->hash = '';
if (!is_array($value)) {
$value = preg_split('/\s*[#,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
$this->tags = $value;
}
public function matches(FreshRSS_BooleanSearch $booleanSearch): bool {
$ok = true;
foreach ($booleanSearch->searches() as $filter) {
if ($filter instanceof FreshRSS_BooleanSearch) {
// BooleanSearches are combined by AND (default) or OR or AND NOT (special cases) operators and are recursive
switch ($filter->operator()) {
case 'OR':
$ok |= $this->matches($filter);
break;
case 'OR NOT':
$ok |= !$this->matches($filter);
break;
case 'AND NOT':
$ok &= !$this->matches($filter);
break;
case 'AND':
default:
$ok &= $this->matches($filter);
break;
}
} elseif ($filter instanceof FreshRSS_Search) {
// Searches are combined by OR and are not recursive
$ok = true;
if ($filter->getEntryIds()) {
$ok &= in_array($this->id, $filter->getEntryIds(), true);
}
if ($ok && $filter->getNotEntryIds()) {
$ok &= !in_array($this->id, $filter->getNotEntryIds(), true);
}
if ($ok && $filter->getMinDate()) {
$ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0;
}
if ($ok && $filter->getNotMinDate()) {
$ok &= strnatcmp($this->id, $filter->getNotMinDate() . '000000') < 0;
}
if ($ok && $filter->getMaxDate()) {
$ok &= strnatcmp($this->id, $filter->getMaxDate() . '000000') <= 0;
}
if ($ok && $filter->getNotMaxDate()) {
$ok &= strnatcmp($this->id, $filter->getNotMaxDate() . '000000') > 0;
}
if ($ok && $filter->getMinPubdate()) {
$ok &= $this->date >= $filter->getMinPubdate();
}
if ($ok && $filter->getNotMinPubdate()) {
$ok &= $this->date < $filter->getNotMinPubdate();
}
if ($ok && $filter->getMaxPubdate()) {
$ok &= $this->date <= $filter->getMaxPubdate();
}
if ($ok && $filter->getNotMaxPubdate()) {
$ok &= $this->date > $filter->getNotMaxPubdate();
}
if ($ok && $filter->getFeedIds()) {
$ok &= in_array($this->feedId, $filter->getFeedIds(), true);
}
if ($ok && $filter->getNotFeedIds()) {
$ok &= !in_array($this->feedId, $filter->getNotFeedIds(), true);
}
if ($ok && $filter->getAuthor()) {
foreach ($filter->getAuthor() as $author) {
$ok &= stripos(implode(';', $this->authors), $author) !== false;
}
}
if ($ok && $filter->getNotAuthor()) {
foreach ($filter->getNotAuthor() as $author) {
$ok &= stripos(implode(';', $this->authors), $author) === false;
}
}
if ($ok && $filter->getIntitle()) {
foreach ($filter->getIntitle() as $title) {
$ok &= stripos($this->title, $title) !== false;
}
}
if ($ok && $filter->getNotIntitle()) {
foreach ($filter->getNotIntitle() as $title) {
$ok &= stripos($this->title, $title) === false;
}
}
if ($ok && $filter->getTags()) {
foreach ($filter->getTags() as $tag2) {
$found = false;
foreach ($this->tags as $tag1) {
if (strcasecmp($tag1, $tag2) === 0) {
$found = true;
}
}
$ok &= $found;
}
}
if ($ok && $filter->getNotTags()) {
foreach ($filter->getNotTags() as $tag2) {
$found = false;
foreach ($this->tags as $tag1) {
if (strcasecmp($tag1, $tag2) === 0) {
$found = true;
}
}
$ok &= !$found;
}
}
if ($ok && $filter->getInurl()) {
foreach ($filter->getInurl() as $url) {
$ok &= stripos($this->link, $url) !== false;
}
}
if ($ok && $filter->getNotInurl()) {
foreach ($filter->getNotInurl() as $url) {
$ok &= stripos($this->link, $url) === false;
}
}
if ($ok && $filter->getSearch()) {
foreach ($filter->getSearch() as $needle) {
$ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false);
}
}
if ($ok && $filter->getNotSearch()) {
foreach ($filter->getNotSearch() as $needle) {
$ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false);
}
}
if ($ok) {
return true;
}
}
}
return (bool)$ok;
}
/** @param array<string,bool|int> $titlesAsRead */
public function applyFilterActions(array $titlesAsRead = []): void {
$feed = $this->feed;
if ($feed === null) {
return;
}
if (!$this->isRead()) {
if ($feed->attributeBoolean('read_upon_reception') ?? FreshRSS_Context::userConf()->mark_when['reception']) {
$this->_isRead(true);
Minz_ExtensionManager::callHook('entry_auto_read', $this, 'upon_reception');
}
if (!empty($titlesAsRead[$this->title()])) {
Minz_Log::debug('Mark title as read: ' . $this->title());
$this->_isRead(true);
Minz_ExtensionManager::callHook('entry_auto_read', $this, 'same_title_in_feed');
}
}
FreshRSS_Context::userConf()->applyFilterActions($this);
if ($feed->category() !== null) {
$feed->category()->applyFilterActions($this);
}
$feed->applyFilterActions($this);
}
public function isDay(int $day, int $today): bool {
$date = $this->dateAdded(true);
switch ($day) {
case FreshRSS_Days::TODAY:
$tomorrow = $today + 86400;
return $date >= $today && $date < $tomorrow;
case FreshRSS_Days::YESTERDAY:
$yesterday = $today - 86400;
return $date >= $yesterday && $date < $today;
case FreshRSS_Days::BEFORE_YESTERDAY:
$yesterday = $today - 86400;
return $date < $yesterday;
default:
return false;
}
}
/**
* @param string $url Overridden URL. Will default to the entry URL.
* @throws Minz_Exception
*/
public function getContentByParsing(string $url = '', int $maxRedirs = 3): string {
$url = $url ?: htmlspecialchars_decode($this->link(), ENT_QUOTES);
$feed = $this->feed();
if ($url === '' || $feed === null || $feed->pathEntries() === '') {
return '';
}
$cachePath = $feed->cacheFilename($url . '#' . $feed->pathEntries());
$html = httpGet($url, $cachePath, 'html', $feed->attributes(), $feed->curlOptions());
if (strlen($html) > 0) {
$doc = new DOMDocument();
$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
$xpath = new DOMXPath($doc);
if ($maxRedirs > 0) {
//Follow any HTML redirection
$metas = $xpath->query('//meta[@content]') ?: [];
foreach ($metas as $meta) {
if ($meta instanceof DOMElement && strtolower(trim($meta->getAttribute('http-equiv'))) === 'refresh') {
$refresh = preg_replace('/^[0-9.; ]*\s*(url\s*=)?\s*/i', '', trim($meta->getAttribute('content')));
$refresh = SimplePie_Misc::absolutize_url($refresh, $url);
if ($refresh != false && $refresh !== $url) {
return $this->getContentByParsing($refresh, $maxRedirs - 1);
}
}
}
}
$base = $xpath->evaluate('normalize-space(//base/@href)');
if ($base == false || !is_string($base)) {
$base = $url;
} elseif (substr($base, 0, 2) === '//') {
//Protocol-relative URLs "//www.example.net"
$base = (parse_url($url, PHP_URL_SCHEME) ?? 'https') . ':' . $base;
}
$content = '';
$cssSelector = htmlspecialchars_decode($feed->pathEntries(), ENT_QUOTES);
$cssSelector = trim($cssSelector, ', ');
$nodes = $xpath->query((new Gt\CssXPath\Translator($cssSelector, '//'))->asXPath());
if ($nodes != false) {
$path_entries_filter = $feed->attributeString('path_entries_filter') ?? '';
$path_entries_filter = trim($path_entries_filter, ', ');
foreach ($nodes as $node) {
if ($path_entries_filter !== '') {
$filterednodes = $xpath->query((new Gt\CssXPath\Translator($path_entries_filter))->asXPath(), $node) ?: [];
foreach ($filterednodes as $filterednode) {
if ($filterednode->parentNode === null) {
continue;
}
$filterednode->parentNode->removeChild($filterednode);
}
}
$content .= $doc->saveHTML($node) . "\n";
}
}
$html = trim(sanitizeHTML($content, $base));
return $html;
} else {
throw new Minz_Exception();
}
}
public function loadCompleteContent(bool $force = false): bool {
// Gestion du contenu
// Trying to fetch full article content even when feeds do not propose it
$feed = $this->feed();
if ($feed != null && trim($feed->pathEntries()) != '') {
$entryDAO = FreshRSS_Factory::createEntryDao();
$entry = $force ? null : $entryDAO->searchByGuid($this->feedId, $this->guid);
if ($entry) {
// lβarticle existe dΓ©jΓ en BDD, en se contente de recharger ce contenu
$this->content = $entry->content(false);
} else {
try {
// The article is not yet in the database, so letβs fetch it
$fullContent = $this->getContentByParsing();
if ('' !== $fullContent) {
$fullContent = "<!-- FULLCONTENT start //-->{$fullContent}<!-- FULLCONTENT end //-->";
$originalContent = $this->originalContent();
switch ($feed->attributeString('content_action')) {
case 'prepend':
$this->content = $fullContent . $originalContent;
break;
case 'append':
$this->content = $originalContent . $fullContent;
break;
case 'replace':
default:
$this->content = $fullContent;
break;
}
return true;
}
} catch (Exception $e) {
// rien Γ faire, on garde lβancien contenu(requΓͺte a Γ©chouΓ©)
Minz_Log::warning($e->getMessage());
}
}
}
return false;
}
/**
* @return array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
* 'hash':string,'is_read':?bool,'is_favorite':?bool,'id_feed':int,'tags':string,'attributes':array<string,mixed>}
*/
public function toArray(): array {
return [
'id' => $this->id(),
'guid' => $this->guid(),
'title' => $this->title(),
'author' => $this->authors(true),
'content' => $this->content(false),
'link' => $this->link(),
'date' => $this->date(true),
'lastSeen' => $this->lastSeen(),
'hash' => $this->hash(),
'is_read' => $this->isRead(),
'is_favorite' => $this->isFavorite(),
'id_feed' => $this->feedId(),
'tags' => $this->tags(true),
'attributes' => $this->attributes(),
];
}
/**
* @return array{array<string>,array<string>} Array of first tags to show, then array of remaining tags
*/
public function tagsFormattingHelper(): array {
$firstTags = [];
$remainingTags = [];
if (FreshRSS_Context::hasUserConf() && in_array(FreshRSS_Context::userConf()->show_tags, ['b', 'f', 'h'], true)) {
$maxTagsDisplayed = (int)FreshRSS_Context::userConf()->show_tags_max;
$tags = $this->tags();
if (!empty($tags)) {
if ($maxTagsDisplayed > 0) {
$firstTags = array_slice($tags, 0, $maxTagsDisplayed);
$remainingTags = array_slice($tags, $maxTagsDisplayed);
} else {
$firstTags = $tags;
}
}
}
return [$firstTags,$remainingTags];
}
/**
* Integer format conversion for Google Reader API format
* @param numeric-string|int $dec Decimal number
* @return string 64-bit hexa http://code.google.com/p/google-reader-api/wiki/ItemId
*/
private static function dec2hex($dec): string {
return PHP_INT_SIZE < 8 ? // 32-bit ?
str_pad(gmp_strval(gmp_init($dec, 10), 16), 16, '0', STR_PAD_LEFT) :
str_pad(dechex((int)($dec)), 16, '0', STR_PAD_LEFT);
}
/**
* Some clients (tested with News+) would fail if sending too long item content
* @var int
*/
public const API_MAX_COMPAT_CONTENT_LENGTH = 500000;
/**
* N.B.: To avoid expensive lookups, ensure to set `$entry->_feed($feed)` before calling this function.
* @param string $mode Set to `'compat'` to use an alternative Unicode representation for problematic HTML special characters not decoded by some clients;
* set to `'freshrss'` for using FreshRSS additions for internal use (e.g. export/import).
* @param array<string> $labels List of labels associated to this entry.
* @return array<string,mixed> A representation of this entry in a format compatible with Google Reader API
*/
public function toGReader(string $mode = '', array $labels = []): array {
$feed = $this->feed();
$category = $feed == null ? null : $feed->category();
$item = [
'id' => 'tag:google.com,2005:reader/item/' . self::dec2hex($this->id()),
'crawlTimeMsec' => substr($this->dateAdded(true, true), 0, -3),
'timestampUsec' => '' . $this->dateAdded(true, true), //EasyRSS & Reeder
'published' => $this->date(true),
// 'updated' => $this->date(true),
'title' => $this->title(),
'canonical' => [
['href' => htmlspecialchars_decode($this->link(), ENT_QUOTES)],
],
'alternate' => [
[
'href' => htmlspecialchars_decode($this->link(), ENT_QUOTES),
'type' => 'text/html',
],
],
'categories' => [
'user/-/state/com.google/reading-list',
],
'origin' => [
'streamId' => 'feed/' . $this->feedId,
],
];
if ($mode === 'compat') {
$item['title'] = escapeToUnicodeAlternative($this->title(), false);
unset($item['alternate'][0]['type']);
$item['summary'] = [
'content' => mb_strcut($this->content(true), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8'),
];
} else {
$item['content'] = [
'content' => $this->content(false),
];
}
if ($mode === 'freshrss') {
$item['guid'] = $this->guid();
}
if ($category != null && $mode !== 'freshrss') {
$item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($category->name(), ENT_QUOTES);
}
if ($feed != null) {
$item['origin']['htmlUrl'] = htmlspecialchars_decode($feed->website());
$item['origin']['title'] = $feed->name(); //EasyRSS
if ($mode === 'compat') {
$item['origin']['title'] = escapeToUnicodeAlternative($feed->name(), true);
} elseif ($mode === 'freshrss') {
$item['origin']['feedUrl'] = htmlspecialchars_decode($feed->url());
}
}
foreach ($this->enclosures() as $enclosure) {
if (!empty($enclosure['url'])) {
$media = [
'href' => $enclosure['url'],
'type' => $enclosure['type'] ?? $enclosure['medium'] ??
(self::enclosureIsImage($enclosure) ? 'image' : ''),
];
if (!empty($enclosure['length'])) {
$media['length'] = (int)$enclosure['length'];
}
$item['enclosure'][] = $media;
}
}
$author = $this->authors(true);
$author = trim($author, '; ');
if ($author != '') {
if ($mode === 'compat') {
$item['author'] = escapeToUnicodeAlternative($author, false);
} else {
$item['author'] = $author;
}
}
if ($this->isRead()) {
$item['categories'][] = 'user/-/state/com.google/read';
} elseif ($mode === 'freshrss') {
$item['categories'][] = 'user/-/state/com.google/unread';
}
if ($this->isFavorite()) {
$item['categories'][] = 'user/-/state/com.google/starred';
}
foreach ($labels as $labelName) {
$item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($labelName, ENT_QUOTES);
}
foreach ($this->tags() as $tagName) {
$item['categories'][] = htmlspecialchars_decode($tagName, ENT_QUOTES);
}
return $item;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/EntryDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_EntryDAO extends Minz_ModelPdo {
public static function isCompressed(): bool {
return true;
}
public static function hasNativeHex(): bool {
return true;
}
protected static function sqlConcat(string $s1, string $s2): string {
return 'CONCAT(' . $s1 . ',' . $s2 . ')'; //MySQL
}
public static function sqlHexDecode(string $x): string {
return 'unhex(' . $x . ')';
}
public static function sqlHexEncode(string $x): string {
return 'hex(' . $x . ')';
}
public static function sqlIgnoreConflict(string $sql): string {
return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql);
}
private function updateToMediumBlob(): bool {
if ($this->pdo->dbType() !== 'mysql') {
return false;
}
Minz_Log::warning('Update MySQL table to use MEDIUMBLOB...');
$sql = <<<'SQL'
ALTER TABLE `_entry` MODIFY `content_bin` MEDIUMBLOB;
ALTER TABLE `_entrytmp` MODIFY `content_bin` MEDIUMBLOB;
SQL;
try {
$ok = $this->pdo->exec($sql) !== false;
} catch (Exception $e) {
$ok = false;
Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
}
return $ok;
}
protected function addColumn(string $name): bool {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
Minz_Log::warning(__method__ . ': ' . $name);
try {
if ($name === 'attributes') { //v1.20.0
$sql = <<<'SQL'
ALTER TABLE `_entry` ADD COLUMN attributes TEXT;
ALTER TABLE `_entrytmp` ADD COLUMN attributes TEXT;
SQL;
return $this->pdo->exec($sql) !== false;
}
} catch (Exception $e) {
Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
}
return false;
}
//TODO: Move the database auto-updates to DatabaseDAO
/** @param array<string|int> $errorInfo */
protected function autoUpdateDb(array $errorInfo): bool {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", (string)$errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['attributes'] as $column) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
}
}
if (isset($errorInfo[1])) {
// May be a string or an int
if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_DATA_TOO_LONG) {
if (stripos((string)$errorInfo[2], 'content_bin') !== false) {
return $this->updateToMediumBlob(); //v1.15.0
}
}
}
return false;
}
/**
* @var PDOStatement|null|false
*/
private $addEntryPrepared = false;
/** @param array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,'hash':string,
* 'is_read':bool|int|null,'is_favorite':bool|int|null,'id_feed':int,'tags':string,'attributes'?:null|string|array<string,mixed>} $valuesTmp */
public function addEntry(array $valuesTmp, bool $useTmpTable = true): bool {
if ($this->addEntryPrepared == null) {
$sql = static::sqlIgnoreConflict(
'INSERT INTO `_' . ($useTmpTable ? 'entrytmp' : 'entry') . '` (id, guid, title, author, '
. (static::isCompressed() ? 'content_bin' : 'content')
. ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes) '
. 'VALUES(:id, :guid, :title, :author, '
. (static::isCompressed() ? 'COMPRESS(:content)' : ':content')
. ', :link, :date, :last_seen, '
. static::sqlHexDecode(':hash')
. ', :is_read, :is_favorite, :id_feed, :tags, :attributes)');
$this->addEntryPrepared = $this->pdo->prepare($sql);
}
if ($this->addEntryPrepared) {
$this->addEntryPrepared->bindParam(':id', $valuesTmp['id']);
$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
$this->addEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
$this->addEntryPrepared->bindParam(':title', $valuesTmp['title']);
$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
$this->addEntryPrepared->bindParam(':author', $valuesTmp['author']);
$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
$this->addEntryPrepared->bindParam(':content', $valuesTmp['content']);
$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
$this->addEntryPrepared->bindParam(':link', $valuesTmp['link']);
$this->addEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
if (empty($valuesTmp['lastSeen'])) {
$valuesTmp['lastSeen'] = time();
}
$this->addEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
$valuesTmp['is_read'] = $valuesTmp['is_read'] ? 1 : 0;
$this->addEntryPrepared->bindParam(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
$valuesTmp['is_favorite'] = $valuesTmp['is_favorite'] ? 1 : 0;
$this->addEntryPrepared->bindParam(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
$this->addEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, 'UTF-8');
$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$this->addEntryPrepared->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if (static::hasNativeHex()) {
$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
} else {
$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
}
}
if ($this->addEntryPrepared && $this->addEntryPrepared->execute()) {
return true;
} else {
$info = $this->addEntryPrepared == null ? $this->pdo->errorInfo() : $this->addEntryPrepared->errorInfo();
if ($this->autoUpdateDb($info)) {
$this->addEntryPrepared = null;
return $this->addEntry($valuesTmp);
} elseif ((int)((int)$info[0] / 1000) !== 23) { //Filter out "SQLSTATE Class code 23: Constraint Violation" because of expected duplicate entries
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
}
return false;
}
}
public function commitNewEntries(): bool {
$sql = <<<'SQL'
SET @rank=(SELECT MAX(id) - COUNT(*) FROM `_entrytmp`);
INSERT IGNORE INTO `_entry` (
id, guid, title, author, content_bin, link, date, `lastSeen`,
hash, is_read, is_favorite, id_feed, tags, attributes
)
SELECT @rank:=@rank+1 AS id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entrytmp`
ORDER BY date, id;
DELETE FROM `_entrytmp` WHERE id <= @rank;
SQL;
$hadTransaction = $this->pdo->inTransaction();
if (!$hadTransaction) {
$this->pdo->beginTransaction();
}
$result = $this->pdo->exec($sql) !== false;
if (!$hadTransaction) {
$this->pdo->commit();
}
return $result;
}
private ?PDOStatement $updateEntryPrepared = null;
/** @param array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,'hash':string,
* 'is_read':bool|int|null,'is_favorite':bool|int|null,'id_feed':int,'tags':string,'attributes':array<string,mixed>} $valuesTmp */
public function updateEntry(array $valuesTmp): bool {
if (!isset($valuesTmp['is_read'])) {
$valuesTmp['is_read'] = null;
}
if (!isset($valuesTmp['is_favorite'])) {
$valuesTmp['is_favorite'] = null;
}
if ($this->updateEntryPrepared === null) {
$sql = 'UPDATE `_entry` '
. 'SET title=:title, author=:author, '
. (static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content')
. ', link=:link, date=:date, `lastSeen`=:last_seen'
. ', hash=' . static::sqlHexDecode(':hash')
. ', is_read=COALESCE(:is_read, is_read)'
. ', is_favorite=COALESCE(:is_favorite, is_favorite)'
. ', tags=:tags, attributes=:attributes '
. 'WHERE id_feed=:id_feed AND guid=:guid';
$this->updateEntryPrepared = $this->pdo->prepare($sql) ?: null;
}
if ($this->updateEntryPrepared) {
$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 767);
$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
$this->updateEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
$valuesTmp['title'] = mb_strcut($valuesTmp['title'], 0, 8192, 'UTF-8');
$valuesTmp['title'] = safe_utf8($valuesTmp['title']);
$this->updateEntryPrepared->bindParam(':title', $valuesTmp['title']);
$valuesTmp['author'] = mb_strcut($valuesTmp['author'], 0, 1024, 'UTF-8');
$valuesTmp['author'] = safe_utf8($valuesTmp['author']);
$this->updateEntryPrepared->bindParam(':author', $valuesTmp['author']);
$valuesTmp['content'] = safe_utf8($valuesTmp['content']);
$this->updateEntryPrepared->bindParam(':content', $valuesTmp['content']);
$valuesTmp['link'] = substr($valuesTmp['link'], 0, 16383);
$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
$this->updateEntryPrepared->bindParam(':link', $valuesTmp['link']);
$this->updateEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
$this->updateEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
if ($valuesTmp['is_read'] === null) {
$this->updateEntryPrepared->bindValue(':is_read', null, PDO::PARAM_NULL);
} else {
$this->updateEntryPrepared->bindValue(':is_read', $valuesTmp['is_read'] ? 1 : 0, PDO::PARAM_INT);
}
if ($valuesTmp['is_favorite'] === null) {
$this->updateEntryPrepared->bindValue(':is_favorite', null, PDO::PARAM_NULL);
} else {
$this->updateEntryPrepared->bindValue(':is_favorite', $valuesTmp['is_favorite'] ? 1 : 0, PDO::PARAM_INT);
}
$this->updateEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
$valuesTmp['tags'] = mb_strcut($valuesTmp['tags'], 0, 2048, 'UTF-8');
$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$this->updateEntryPrepared->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if (static::hasNativeHex()) {
$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
} else {
$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
}
}
if ($this->updateEntryPrepared && $this->updateEntryPrepared->execute()) {
return true;
} else {
$info = $this->updateEntryPrepared == null ? $this->pdo->errorInfo() : $this->updateEntryPrepared->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->updateEntry($valuesTmp);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
. ' while updating entry with GUID ' . $valuesTmp['guid'] . ' in feed ' . $valuesTmp['id_feed']);
return false;
}
}
/**
* Count the number of new entries in the temporary table (which have not yet been committed).
*/
public function countNewEntries(): int {
$sql = <<<'SQL'
SELECT COUNT(id) AS nb_entries FROM `_entrytmp`
SQL;
$res = $this->fetchColumn($sql, 0);
return isset($res[0]) ? (int)$res[0] : -1;
}
/**
* Toggle favorite marker on one or more article
*
* @todo simplify the query by removing the str_repeat. I am pretty sure
* there is an other way to do that.
*
* @param numeric-string|array<numeric-string> $ids
* @return int|false
*/
public function markFavorite($ids, bool $is_favorite = true) {
if (!is_array($ids)) {
$ids = [$ids];
}
if (count($ids) < 1) {
return 0;
}
FreshRSS_UserDAO::touch();
if (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$affected = 0;
$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($idsChunks as $idsChunk) {
$affected += ($this->markFavorite($idsChunk, $is_favorite) ?: 0);
}
return $affected;
}
$sql = 'UPDATE `_entry` '
. 'SET is_favorite=? '
. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
$values = [$is_favorite ? 1 : 0];
$values = array_merge($values, $ids);
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Update the unread article cache held on every feed details.
* Depending on the parameters, it updates the cache on one feed, on all
* feeds from one category or on all feeds.
*/
protected function updateCacheUnreads(?int $catId = null, ?int $feedId = null): bool {
// Help MySQL/MariaDB's optimizer with the query plan:
$useIndex = $this->pdo->dbType() === 'mysql' ? 'USE INDEX (entry_feed_read_index)' : '';
$sql = <<<SQL
UPDATE `_feed`
SET `cache_nbUnreads`=(
SELECT COUNT(*) AS nbUnreads FROM `_entry` e {$useIndex}
WHERE e.id_feed=`_feed`.id AND e.is_read=0)
SQL;
$hasWhere = false;
$values = [];
if ($feedId != null) {
$sql .= ' WHERE';
$hasWhere = true;
$sql .= ' id=?';
$values[] = $feedId;
}
if ($catId != null) {
$sql .= $hasWhere ? ' AND' : ' WHERE';
$hasWhere = true;
$sql .= ' category=?';
$values[] = $catId;
}
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return true;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Toggle the read marker on one or more article.
* Then the cache is updated.
*
* @param numeric-string|array<numeric-string> $ids
* @param bool $is_read
* @return int|false affected rows
*/
public function markRead($ids, bool $is_read = true) {
if (is_array($ids)) { //Many IDs at once
if (count($ids) < 6) { //Speed heuristics
$affected = 0;
foreach ($ids as $id) {
$affected += ($this->markRead($id, $is_read) ?: 0);
}
return $affected;
} elseif (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$affected = 0;
$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($idsChunks as $idsChunk) {
$affected += ($this->markRead($idsChunk, $is_read) ?: 0);
}
return $affected;
}
FreshRSS_UserDAO::touch();
$sql = 'UPDATE `_entry` '
. 'SET is_read=? '
. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1) . '?)';
$values = [$is_read ? 1 : 0];
$values = array_merge($values, $ids);
$stm = $this->pdo->prepare($sql);
if (!($stm && $stm->execute($values))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
return $affected;
} else {
FreshRSS_UserDAO::touch();
$sql = 'UPDATE `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id '
. 'SET e.is_read=?,'
. 'f.`cache_nbUnreads`=f.`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
. 'WHERE e.id=? AND e.is_read=?';
$values = [$is_read ? 1 : 0, $ids, $is_read ? 0 : 1];
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
return false;
}
}
}
/**
* Mark all entries as read depending on parameters.
* If $onlyFavorites is true, it is used when the user mark as read in
* the favorite pseudo-category.
* If $priorityMin is greater than 0, it is used when the user mark as
* read in the main feed pseudo-category.
* Then the cache is updated.
*
* If $idMax equals 0, a deprecated debug message is logged
*
* @param numeric-string $idMax fail safe article ID
* @return int|false affected rows
*/
public function markReadEntries(string $idMax = '0', bool $onlyFavorites = false, ?int $priorityMin = null, ?int $prioritMax = null,
?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
FreshRSS_UserDAO::touch();
if ($idMax == '0') {
$idMax = time() . '000000';
Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
}
$sql = 'UPDATE `_entry` SET is_read = ? WHERE is_read <> ? AND id <= ?';
$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax];
if ($onlyFavorites) {
$sql .= ' AND is_favorite=1';
}
if ($priorityMin !== null || $prioritMax !== null) {
$sql .= ' AND id_feed IN (SELECT f.id FROM `_feed` f WHERE 1=1';
if ($priorityMin !== null) {
$sql .= ' AND f.priority >= ?';
$values[] = $priorityMin;
}
if ($prioritMax !== null) {
$sql .= ' AND f.priority < ?';
$values[] = $prioritMax;
}
$sql .= ')';
}
[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);
$stm = $this->pdo->prepare($sql . $search);
if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
return $affected;
}
/**
* Mark all the articles in a category as read.
* There is a fail safe to prevent to mark as read articles that are
* loaded during the mark as read action. Then the cache is updated.
*
* If $idMax equals 0, a deprecated debug message is logged
*
* @param int $id category ID
* @param numeric-string $idMax fail safe article ID
* @return int|false affected rows
*/
public function markReadCat(int $id, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
FreshRSS_UserDAO::touch();
if ($idMax == '0') {
$idMax = time() . '000000';
Minz_Log::debug('Calling markReadCat(0) is deprecated!');
}
$sql = <<<'SQL'
UPDATE `_entry`
SET is_read = ?
WHERE is_read <> ? AND id <= ?
AND id_feed IN (SELECT f.id FROM `_feed` f WHERE f.category=?)
SQL;
$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax, $id];
[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);
$stm = $this->pdo->prepare($sql . $search);
if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads($id, null))) {
return false;
}
return $affected;
}
/**
* Mark all the articles in a feed as read.
* There is a fail safe to prevent to mark as read articles that are
* loaded during the mark as read action. Then the cache is updated.
*
* If $idMax equals 0, a deprecated debug message is logged
*
* @param int $id_feed feed ID
* @param numeric-string $idMax fail safe article ID
* @return int|false affected rows
*/
public function markReadFeed(int $id_feed, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
FreshRSS_UserDAO::touch();
if ($idMax == '0') {
$idMax = time() . '000000';
Minz_Log::debug('Calling markReadFeed(0) is deprecated!');
}
$hadTransaction = $this->pdo->inTransaction();
if (!$hadTransaction) {
$this->pdo->beginTransaction();
}
$sql = 'UPDATE `_entry` '
. 'SET is_read=? '
. 'WHERE id_feed=? AND is_read <> ? AND id <= ?';
$values = [$is_read ? 1 : 0, $id_feed, $is_read ? 1 : 0, $idMax];
[$searchValues, $search] = $this->sqlListEntriesWhere('', $filters, $state);
$stm = $this->pdo->prepare($sql . $search);
if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' with SQL: ' . $sql . $search);
$this->pdo->rollBack();
return false;
}
$affected = $stm->rowCount();
if ($affected > 0) {
$sql = 'UPDATE `_feed` '
. 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
. ' WHERE id=:id';
$stm = $this->pdo->prepare($sql);
if (!($stm !== false &&
$stm->bindParam(':id', $id_feed, PDO::PARAM_INT) &&
$stm->execute())) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
$this->pdo->rollBack();
return false;
}
}
if (!$hadTransaction) {
$this->pdo->commit();
}
return $affected;
}
/**
* Mark all the articles in a tag as read.
* @param int $id tag ID, or empty for targeting any tag
* @param numeric-string $idMax max article ID
* @return int|false affected rows
*/
public function markReadTag(int $id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null,
int $state = 0, bool $is_read = true) {
FreshRSS_UserDAO::touch();
if ($idMax == '0') {
$idMax = time() . '000000';
Minz_Log::debug('Calling markReadTag(0) is deprecated!');
}
$sql = 'UPDATE `_entry` e INNER JOIN `_entrytag` et ON et.id_entry = e.id '
. 'SET e.is_read = ? '
. 'WHERE '
. ($id == 0 ? '' : 'et.id_tag = ? AND ')
. 'e.is_read <> ? AND e.id <= ?';
$values = [$is_read ? 1 : 0];
if ($id != 0) {
$values[] = $id;
}
$values[] = $is_read ? 1 : 0;
$values[] = $idMax;
[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state);
$stm = $this->pdo->prepare($sql . $search);
if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
return $affected;
}
/**
* Remember to call updateCachedValues($id_feed) or updateCachedValues() just after.
* @param array<string,bool|int|string> $options
* @return int|false
*/
public function cleanOldEntries(int $id_feed, array $options = []) {
$sql = 'DELETE FROM `_entry` WHERE id_feed = :id_feed1'; //No alias for MySQL / MariaDB
$params = [];
$params[':id_feed1'] = $id_feed;
//==Exclusions==
if (!empty($options['keep_favourites'])) {
$sql .= ' AND is_favorite = 0';
}
if (!empty($options['keep_unreads'])) {
$sql .= ' AND is_read = 1';
}
if (!empty($options['keep_labels'])) {
$sql .= ' AND NOT EXISTS (SELECT 1 FROM `_entrytag` WHERE id_entry = id)';
}
if (!empty($options['keep_min']) && $options['keep_min'] > 0) {
//Double SELECT for MySQL workaround ERROR 1093 (HY000)
$sql .= ' AND `lastSeen` < (SELECT `lastSeen`'
. ' FROM (SELECT e2.`lastSeen` FROM `_entry` e2 WHERE e2.id_feed = :id_feed2'
. ' ORDER BY e2.`lastSeen` DESC LIMIT 1 OFFSET :keep_min) last_seen2)';
$params[':id_feed2'] = $id_feed;
$params[':keep_min'] = (int)$options['keep_min'];
}
//Keep at least the articles seen at the last refresh
$sql .= ' AND `lastSeen` < (SELECT maxlastseen'
. ' FROM (SELECT MAX(e3.`lastSeen`) AS maxlastseen FROM `_entry` e3 WHERE e3.id_feed = :id_feed3) last_seen3)';
$params[':id_feed3'] = $id_feed;
//==Inclusions==
$sql .= ' AND (1=0';
if (!empty($options['keep_period']) && is_string($options['keep_period'])) {
$sql .= ' OR `lastSeen` < :max_last_seen';
$now = new DateTime('now');
$now->sub(new DateInterval($options['keep_period']));
$params[':max_last_seen'] = $now->format('U');
}
if (!empty($options['keep_max']) && $options['keep_max'] > 0) {
$sql .= ' OR `lastSeen` <= (SELECT `lastSeen`'
. ' FROM (SELECT e4.`lastSeen` FROM `_entry` e4 WHERE e4.id_feed = :id_feed4'
. ' ORDER BY e4.`lastSeen` DESC LIMIT 1 OFFSET :keep_max) last_seen4)';
$params[':id_feed4'] = $id_feed;
$params[':keep_max'] = (int)$options['keep_max'];
}
$sql .= ')';
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($params)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->cleanOldEntries($id_feed, $options);
}
Minz_Log::error(__method__ . ' error:' . json_encode($info));
return false;
}
}
/** @return Traversable<array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
* 'hash':string,'is_read':bool,'is_favorite':bool,'id_feed':int,'tags':string,'attributes':?string}> */
public function selectAll(?int $limit = null): Traversable {
$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
$hash = static::sqlHexEncode('hash');
$sql = <<<SQL
SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entry`
SQL;
if (is_int($limit) && $limit >= 0) {
$sql .= ' ORDER BY id DESC LIMIT ' . $limit;
}
$stm = $this->pdo->query($sql);
if ($stm != false) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
/** @var array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,
* 'hash':string,'is_read':bool,'is_favorite':bool,'id_feed':int,'tags':string,'attributes':?string} $row */
yield $row;
}
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
yield from $this->selectAll();
} else {
Minz_Log::error(__method__ . ' error: ' . json_encode($info));
}
}
}
public function searchByGuid(int $id_feed, string $guid): ?FreshRSS_Entry {
$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
$hash = static::sqlHexEncode('hash');
$sql = <<<SQL
SELECT id, guid, title, author, link, date, is_read, is_favorite, {$hash} AS hash, id_feed, tags, attributes, {$content}
FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
SQL;
$res = $this->fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]);
/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
* 'is_read':int,'is_favorite':int,'tags':string,'attributes':?string}> $res */
return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
}
public function searchById(string $id): ?FreshRSS_Entry {
$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
$hash = static::sqlHexEncode('hash');
$sql = <<<SQL
SELECT id, guid, title, author, link, date, is_read, is_favorite, {$hash} AS hash, id_feed, tags, attributes, {$content}
FROM `_entry` WHERE id=:id
SQL;
$res = $this->fetchAssoc($sql, [':id' => $id]);
/** @var array<array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
* 'is_read':int,'is_favorite':int,'tags':string,'attributes':?string}> $res */
return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
}
public function searchIdByGuid(int $id_feed, string $guid): ?string {
$sql = 'SELECT id FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
$res = $this->fetchColumn($sql, 0, [':id_feed' => $id_feed, ':guid' => $guid]);
return empty($res[0]) ? null : (string)($res[0]);
}
/** @return array{0:array<int|string>,1:string} */
public static function sqlBooleanSearch(string $alias, FreshRSS_BooleanSearch $filters, int $level = 0): array {
$search = '';
$values = [];
$isOpen = false;
foreach ($filters->searches() as $filter) {
if ($filter == null) {
continue;
}
if ($filter instanceof FreshRSS_BooleanSearch) {
// BooleanSearches are combined by AND (default) or OR (special case) operator and are recursive
[$filterValues, $filterSearch] = self::sqlBooleanSearch($alias, $filter, $level + 1);
$filterSearch = trim($filterSearch);
if ($filterSearch !== '') {
if ($search !== '') {
$search .= $filter->operator();
} elseif (in_array($filter->operator(), ['AND NOT', 'OR NOT'], true)) {
// Special case if we start with a negation (there is already the default AND before)
$search .= ' NOT';
}
$search .= ' (' . $filterSearch . ') ';
$values = array_merge($values, $filterValues);
}
continue;
}
// Searches are combined by OR and are not recursive
$sub_search = '';
if ($filter->getEntryIds() !== null) {
$sub_search .= 'AND ' . $alias . 'id IN (';
foreach ($filter->getEntryIds() as $entry_id) {
$sub_search .= '?,';
$values[] = $entry_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ') ';
}
if ($filter->getNotEntryIds() !== null) {
$sub_search .= 'AND ' . $alias . 'id NOT IN (';
foreach ($filter->getNotEntryIds() as $entry_id) {
$sub_search .= '?,';
$values[] = $entry_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ') ';
}
if ($filter->getMinDate() !== null) {
$sub_search .= 'AND ' . $alias . 'id >= ? ';
$values[] = "{$filter->getMinDate()}000000";
}
if ($filter->getMaxDate() !== null) {
$sub_search .= 'AND ' . $alias . 'id <= ? ';
$values[] = "{$filter->getMaxDate()}000000";
}
if ($filter->getMinPubdate() !== null) {
$sub_search .= 'AND ' . $alias . 'date >= ? ';
$values[] = $filter->getMinPubdate();
}
if ($filter->getMaxPubdate() !== null) {
$sub_search .= 'AND ' . $alias . 'date <= ? ';
$values[] = $filter->getMaxPubdate();
}
//Negation of date intervals must be combined by OR
if ($filter->getNotMinDate() !== null || $filter->getNotMaxDate() !== null) {
$sub_search .= 'AND (';
if ($filter->getNotMinDate() !== null) {
$sub_search .= $alias . 'id < ?';
$values[] = "{$filter->getNotMinDate()}000000";
if ($filter->getNotMaxDate()) {
$sub_search .= ' OR ';
}
}
if ($filter->getNotMaxDate() !== null) {
$sub_search .= $alias . 'id > ?';
$values[] = "{$filter->getNotMaxDate()}000000";
}
$sub_search .= ') ';
}
if ($filter->getNotMinPubdate() !== null || $filter->getNotMaxPubdate() !== null) {
$sub_search .= 'AND (';
if ($filter->getNotMinPubdate() !== null) {
$sub_search .= $alias . 'date < ?';
$values[] = $filter->getNotMinPubdate();
if ($filter->getNotMaxPubdate()) {
$sub_search .= ' OR ';
}
}
if ($filter->getNotMaxPubdate() !== null) {
$sub_search .= $alias . 'date > ?';
$values[] = $filter->getNotMaxPubdate();
}
$sub_search .= ') ';
}
if ($filter->getFeedIds() !== null) {
$sub_search .= 'AND ' . $alias . 'id_feed IN (';
foreach ($filter->getFeedIds() as $feed_id) {
$sub_search .= '?,';
$values[] = $feed_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ') ';
}
if ($filter->getNotFeedIds() !== null) {
$sub_search .= 'AND ' . $alias . 'id_feed NOT IN (';
foreach ($filter->getNotFeedIds() as $feed_id) {
$sub_search .= '?,';
$values[] = $feed_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ') ';
}
if ($filter->getLabelIds() !== null) {
if ($filter->getLabelIds() === '*') {
$sub_search .= 'AND EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
} else {
$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
foreach ($filter->getLabelIds() as $label_id) {
$sub_search .= '?,';
$values[] = $label_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ')) ';
}
}
if ($filter->getNotLabelIds() !== null) {
if ($filter->getNotLabelIds() === '*') {
$sub_search .= 'AND NOT EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) ';
} else {
$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (';
foreach ($filter->getNotLabelIds() as $label_id) {
$sub_search .= '?,';
$values[] = $label_id;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ')) ';
}
}
if ($filter->getLabelNames() !== null) {
$sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
foreach ($filter->getLabelNames() as $label_name) {
$sub_search .= '?,';
$values[] = $label_name;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ')) ';
}
if ($filter->getNotLabelNames() !== null) {
$sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (';
foreach ($filter->getNotLabelNames() as $label_name) {
$sub_search .= '?,';
$values[] = $label_name;
}
$sub_search = rtrim($sub_search, ',');
$sub_search .= ')) ';
}
if ($filter->getAuthor() !== null) {
foreach ($filter->getAuthor() as $author) {
$sub_search .= 'AND ' . $alias . 'author LIKE ? ';
$values[] = "%{$author}%";
}
}
if ($filter->getIntitle() !== null) {
foreach ($filter->getIntitle() as $title) {
$sub_search .= 'AND ' . $alias . 'title LIKE ? ';
$values[] = "%{$title}%";
}
}
if ($filter->getTags() !== null) {
foreach ($filter->getTags() as $tag) {
$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? ';
$values[] = "%{$tag} #%";
}
}
if ($filter->getInurl() !== null) {
foreach ($filter->getInurl() as $url) {
$sub_search .= 'AND ' . $alias . 'link LIKE ? ';
$values[] = "%{$url}%";
}
}
if ($filter->getNotAuthor() !== null) {
foreach ($filter->getNotAuthor() as $author) {
$sub_search .= 'AND ' . $alias . 'author NOT LIKE ? ';
$values[] = "%{$author}%";
}
}
if ($filter->getNotIntitle() !== null) {
foreach ($filter->getNotIntitle() as $title) {
$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? ';
$values[] = "%{$title}%";
}
}
if ($filter->getNotTags() !== null) {
foreach ($filter->getNotTags() as $tag) {
$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? ';
$values[] = "%{$tag} #%";
}
}
if ($filter->getNotInurl() !== null) {
foreach ($filter->getNotInurl() as $url) {
$sub_search .= 'AND ' . $alias . 'link NOT LIKE ? ';
$values[] = "%{$url}%";
}
}
if ($filter->getSearch() !== null) {
foreach ($filter->getSearch() as $search_value) {
if (static::isCompressed()) { // MySQL-only
$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) LIKE ? ';
$values[] = "%{$search_value}%";
} else {
$sub_search .= 'AND (' . $alias . 'title LIKE ? OR ' . $alias . 'content LIKE ?) ';
$values[] = "%{$search_value}%";
$values[] = "%{$search_value}%";
}
}
}
if ($filter->getNotSearch() !== null) {
foreach ($filter->getNotSearch() as $search_value) {
if (static::isCompressed()) { // MySQL-only
$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) NOT LIKE ? ';
$values[] = "%{$search_value}%";
} else {
$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? AND ' . $alias . 'content NOT LIKE ? ';
$values[] = "%{$search_value}%";
$values[] = "%{$search_value}%";
}
}
}
if ($sub_search != '') {
if ($isOpen) {
$search .= ' OR ';
} else {
$isOpen = true;
}
// Remove superfluous leading 'AND '
$search .= '(' . substr($sub_search, 4) . ')';
}
}
return [ $values, $search ];
}
/**
* @param 'ASC'|'DESC' $order
* @return array{0:array<int|string>,1:string}
* @throws FreshRSS_EntriesGetter_Exception
*/
protected function sqlListEntriesWhere(string $alias = '', ?FreshRSS_BooleanSearch $filters = null,
int $state = FreshRSS_Entry::STATE_ALL,
string $order = 'DESC', string $firstId = '', int $date_min = 0): array {
$search = ' ';
$values = [];
if ($state & FreshRSS_Entry::STATE_NOT_READ) {
if (!($state & FreshRSS_Entry::STATE_READ)) {
$search .= 'AND ' . $alias . 'is_read=0 ';
}
} elseif ($state & FreshRSS_Entry::STATE_READ) {
$search .= 'AND ' . $alias . 'is_read=1 ';
}
if ($state & FreshRSS_Entry::STATE_FAVORITE) {
if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
$search .= 'AND ' . $alias . 'is_favorite=1 ';
}
} elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
$search .= 'AND ' . $alias . 'is_favorite=0 ';
}
switch ($order) {
case 'DESC':
case 'ASC':
break;
default:
throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!');
}
if ($firstId !== '') {
$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . ' ? ';
$values[] = $firstId;
}
if ($date_min > 0) {
$search .= 'AND ' . $alias . 'id >= ? ';
$values[] = $date_min . '000000';
}
if ($filters && count($filters->searches()) > 0) {
[$filterValues, $filterSearch] = self::sqlBooleanSearch($alias, $filters);
$filterSearch = trim($filterSearch);
if ($filterSearch !== '') {
$search .= 'AND (' . $filterSearch . ') ';
$values = array_merge($values, $filterValues);
}
}
return [$values, $search];
}
/**
* @phpstan-param 'a'|'A'|'i'|'s'|'S'|'c'|'f'|'t'|'T'|'ST' $type
* @param int $id category/feed/tag ID
* @param 'ASC'|'DESC' $order
* @return array{0:array<int|string>,1:string}
* @throws FreshRSS_EntriesGetter_Exception
*/
private function sqlListWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
int $date_min = 0): array {
if (!$state) {
$state = FreshRSS_Entry::STATE_ALL;
}
$where = '';
$values = [];
switch ($type) {
case 'a': //All PRIORITY_MAIN_STREAM
$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_MAIN_STREAM . ' ';
break;
case 'A': //All except PRIORITY_ARCHIVED
$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_ARCHIVED . ' ';
break;
case 'i': //Priority important feeds
$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_IMPORTANT . ' ';
break;
case 's': //Starred. Deprecated: use $state instead
$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_ARCHIVED . ' ';
$where .= 'AND e.is_favorite=1 ';
break;
case 'S': //Starred
$where .= 'e.is_favorite=1 ';
break;
case 'c': //Category
$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_CATEGORY . ' ';
$where .= 'AND f.category=? ';
$values[] = $id;
break;
case 'f': //Feed
$where .= 'e.id_feed=? ';
$values[] = $id;
break;
case 't': //Tag (label)
$where .= 'et.id_tag=? ';
$values[] = $id;
break;
case 'T': //Any tag (label)
$where .= '1=1 ';
break;
case 'ST': //Starred or tagged (label)
$where .= 'e.is_favorite=1 OR EXISTS (SELECT et2.id_tag FROM `_entrytag` et2 WHERE et2.id_entry = e.id) ';
break;
default:
throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
}
[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state, $order, $firstId, $date_min);
return [array_merge($values, $searchValues), 'SELECT '
. ($type === 'T' ? 'DISTINCT ' : '')
. 'e.id FROM `_entry` e '
. 'INNER JOIN `_feed` f ON e.id_feed = f.id '
. ($type === 't' || $type === 'T' ? 'INNER JOIN `_entrytag` et ON et.id_entry = e.id ' : '')
. 'WHERE ' . $where
. $search
. 'ORDER BY e.id ' . $order
. ($limit > 0 ? ' LIMIT ' . $limit : '') // http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
. ($offset > 0 ? ' OFFSET ' . $offset : '')
];
}
/**
* @phpstan-param 'a'|'A'|'s'|'S'|'i'|'c'|'f'|'t'|'T'|'ST' $type
* @param 'ASC'|'DESC' $order
* @param int $id category/feed/tag ID
* @return PDOStatement|false
* @throws FreshRSS_EntriesGetter_Exception
*/
private function listWhereRaw(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
int $date_min = 0) {
[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
if ($order !== 'DESC' && $order !== 'ASC') {
$order = 'DESC';
}
$content = static::isCompressed() ? 'UNCOMPRESS(e0.content_bin) AS content' : 'e0.content';
$hash = static::sqlHexEncode('e0.hash');
$sql = <<<SQL
SELECT e0.id, e0.guid, e0.title, e0.author, {$content}, e0.link, e0.date, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
FROM `_entry` e0
INNER JOIN ({$sql}) e2 ON e2.id=e0.id
ORDER BY e0.id {$order}
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* @phpstan-param 'a'|'A'|'s'|'S'|'i'|'c'|'f'|'t'|'T'|'ST' $type
* @param int $id category/feed/tag ID
* @param 'ASC'|'DESC' $order
* @return Traversable<FreshRSS_Entry>
* @throws FreshRSS_EntriesGetter_Exception
*/
public function listWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '',
?FreshRSS_BooleanSearch $filters = null, int $date_min = 0): Traversable {
$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
if ($stm) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
if (is_array($row)) {
/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
* 'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes'?:?string} $row */
yield FreshRSS_Entry::fromArray($row);
}
}
}
}
/**
* @param array<numeric-string> $ids
* @param 'ASC'|'DESC' $order
* @return Traversable<FreshRSS_Entry>
*/
public function listByIds(array $ids, string $order = 'DESC'): Traversable {
if (count($ids) < 1) {
return;
}
if (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($idsChunks as $idsChunk) {
foreach ($this->listByIds($idsChunk, $order) as $entry) {
yield $entry;
}
}
return;
}
if ($order !== 'DESC' && $order !== 'ASC') {
$order = 'DESC';
}
$content = static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content';
$hash = static::sqlHexEncode('hash');
$repeats = str_repeat('?,', count($ids) - 1) . '?';
$sql = <<<SQL
SELECT id, guid, title, author, link, date, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}
FROM `_entry`
WHERE id IN ({$repeats})
ORDER BY id {$order}
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm === false || !$stm->execute($ids)) {
return;
}
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
if (is_array($row)) {
/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
* 'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes':?string} $row */
yield FreshRSS_Entry::fromArray($row);
}
}
}
/**
* @phpstan-param 'a'|'A'|'s'|'S'|'c'|'f'|'t'|'T'|'ST' $type
* @param int $id category/feed/tag ID
* @param 'ASC'|'DESC' $order
* @return array<numeric-string>|null
* @throws FreshRSS_EntriesGetter_Exception
*/
public function listIdsWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null): ?array {
[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters);
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values) && ($res = $stm->fetchAll(PDO::FETCH_COLUMN, 0)) !== false) {
/** @var array<numeric-string> $res */
return $res;
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return null;
}
/**
* @param array<string> $guids
* @return array<string>|false
*/
public function listHashForFeedGuids(int $id_feed, array $guids) {
$result = [];
if (count($guids) < 1) {
return $result;
} elseif (count($guids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$guidsChunks = array_chunk($guids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($guidsChunks as $guidsChunk) {
$result += $this->listHashForFeedGuids($id_feed, $guidsChunk);
}
return $result;
}
$guids = array_unique($guids);
$sql = 'SELECT guid, ' . static::sqlHexEncode('hash') .
' AS hex_hash FROM `_entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)';
$stm = $this->pdo->prepare($sql);
$values = [$id_feed];
$values = array_merge($values, $guids);
if ($stm !== false && $stm->execute($values)) {
$rows = $stm->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$result[$row['guid']] = $row['hex_hash'];
}
return $result;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listHashForFeedGuids($id_feed, $guids);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
. ' while querying feed ' . $id_feed);
return false;
}
}
/**
* @param array<string> $guids
* @return int|false The number of affected entries, or false if error
*/
public function updateLastSeen(int $id_feed, array $guids, int $mtime = 0) {
if (count($guids) < 1) {
return 0;
} elseif (count($guids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$affected = 0;
$guidsChunks = array_chunk($guids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($guidsChunks as $guidsChunk) {
$affected += ($this->updateLastSeen($id_feed, $guidsChunk, $mtime) ?: 0);
}
return $affected;
}
$sql = 'UPDATE `_entry` SET `lastSeen`=? WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1) . '?)';
$stm = $this->pdo->prepare($sql);
if ($mtime <= 0) {
$mtime = time();
}
$values = [$mtime, $id_feed];
$values = array_merge($values, $guids);
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->updateLastSeen($id_feed, $guids);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info)
. ' while updating feed ' . $id_feed);
return false;
}
}
/**
* Update (touch) the last seen attribute of the latest entries of a given feed.
* Useful when a feed is unchanged / cached.
* To be performed just before {@see FreshRSS_FeedDAO::updateLastUpdate()}
* @return int|false The number of affected entries, or false in case of error
*/
public function updateLastSeenUnchanged(int $id_feed, int $mtime = 0) {
$sql = <<<'SQL'
UPDATE `_entry` SET `lastSeen` = :mtime
WHERE id_feed = :id_feed1 AND `lastSeen` = (
SELECT `lastUpdate` FROM `_feed` f
WHERE f.id = :id_feed2
)
SQL;
$stm = $this->pdo->prepare($sql);
if ($mtime <= 0) {
$mtime = time();
}
if ($stm !== false &&
$stm->bindValue(':mtime', $mtime, PDO::PARAM_INT) &&
$stm->bindValue(':id_feed1', $id_feed, PDO::PARAM_INT) &&
$stm->bindValue(':id_feed2', $id_feed, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' while updating feed ' . $id_feed);
return false;
}
}
/** @return array<string,int> */
public function countUnreadRead(): array {
$sql = <<<'SQL'
SELECT COUNT(e.id) AS count FROM `_entry` e
INNER JOIN `_feed` f ON e.id_feed=f.id
WHERE f.priority > 0
UNION
SELECT COUNT(e.id) AS count FROM `_entry` e
INNER JOIN `_feed` f ON e.id_feed=f.id
WHERE f.priority > 0 AND e.is_read=0
SQL;
$res = $this->fetchColumn($sql, 0);
if ($res === null) {
return ['all' => -1, 'unread' => -1, 'read' => -1];
}
rsort($res);
$all = (int)($res[0] ?? 0);
$unread = (int)($res[1] ?? 0);
return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
}
public function count(?int $minPriority = null): int {
$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
$values = [];
if ($minPriority !== null) {
$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
$sql .= ' WHERE f.priority > :priority';
$values[':priority'] = $minPriority;
}
$res = $this->fetchColumn($sql, 0, $values);
return isset($res[0]) ? (int)($res[0]) : -1;
}
public function countNotRead(?int $minPriority = null): int {
$sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
if ($minPriority !== null) {
$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
}
$sql .= ' WHERE e.is_read=0';
$values = [];
if ($minPriority !== null) {
$sql .= ' AND f.priority > :priority';
$values[':priority'] = $minPriority;
}
$res = $this->fetchColumn($sql, 0, $values);
return isset($res[0]) ? (int)($res[0]) : -1;
}
/** @return array{'all':int,'read':int,'unread':int} */
public function countUnreadReadFavorites(): array {
$sql = <<<'SQL'
SELECT c FROM (
SELECT COUNT(e1.id) AS c, 1 AS o
FROM `_entry` AS e1
JOIN `_feed` AS f1 ON e1.id_feed = f1.id
WHERE e1.is_favorite = 1
AND f1.priority >= :priority1
UNION
SELECT COUNT(e2.id) AS c, 2 AS o
FROM `_entry` AS e2
JOIN `_feed` AS f2 ON e2.id_feed = f2.id
WHERE e2.is_favorite = 1
AND e2.is_read = 0 AND f2.priority >= :priority2
) u
ORDER BY o
SQL;
//Binding a value more than once is not standard and does not work with native prepared statements (e.g. MySQL) https://bugs.php.net/bug.php?id=40417
$res = $this->fetchColumn($sql, 0, [
':priority1' => FreshRSS_Feed::PRIORITY_CATEGORY,
':priority2' => FreshRSS_Feed::PRIORITY_CATEGORY,
]);
if ($res === null) {
return ['all' => -1, 'unread' => -1, 'read' => -1];
}
rsort($res);
$all = (int)($res[0] ?? 0);
$unread = (int)($res[1] ?? 0);
return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/EntryDAOPGSQL.php'
<?php
declare(strict_types=1);
class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
#[\Override]
public static function hasNativeHex(): bool {
return true;
}
#[\Override]
public static function sqlHexDecode(string $x): string {
return 'decode(' . $x . ", 'hex')";
}
#[\Override]
public static function sqlHexEncode(string $x): string {
return 'encode(' . $x . ", 'hex')";
}
#[\Override]
public static function sqlIgnoreConflict(string $sql): string {
return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
}
/** @param array<string|int> $errorInfo */
#[\Override]
protected function autoUpdateDb(array $errorInfo): bool {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", (string)$errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['attributes'] as $column) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
}
}
return false;
}
#[\Override]
public function commitNewEntries(): bool {
//TODO: Update to PostgreSQL 9.5+ syntax with ON CONFLICT DO NOTHING
$sql = 'DO $$
DECLARE
maxrank bigint := (SELECT MAX(id) FROM `_entrytmp`);
rank bigint := (SELECT maxrank - COUNT(*) FROM `_entrytmp`);
BEGIN
INSERT INTO `_entry`
(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
(SELECT rank + row_number() OVER(ORDER BY date, id) AS id, guid, title, author, content,
link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entrytmp` AS etmp
WHERE NOT EXISTS (
SELECT 1 FROM `_entry` AS ereal
WHERE (etmp.id = ereal.id) OR (etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid))
ORDER BY date, id);
DELETE FROM `_entrytmp` WHERE id <= maxrank;
END $$;';
$hadTransaction = $this->pdo->inTransaction();
if (!$hadTransaction) {
$this->pdo->beginTransaction();
}
$result = $this->pdo->exec($sql) !== false;
if (!$hadTransaction) {
$this->pdo->commit();
}
return $result;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/EntryDAOSQLite.php'
<?php
declare(strict_types=1);
class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
#[\Override]
public static function isCompressed(): bool {
return false;
}
#[\Override]
public static function hasNativeHex(): bool {
return false;
}
#[\Override]
protected static function sqlConcat(string $s1, string $s2): string {
return $s1 . '||' . $s2;
}
#[\Override]
public static function sqlHexDecode(string $x): string {
return $x;
}
#[\Override]
public static function sqlIgnoreConflict(string $sql): string {
return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
}
/** @param array<string|int> $errorInfo */
#[\Override]
protected function autoUpdateDb(array $errorInfo): bool {
if ($tableInfo = $this->pdo->query("PRAGMA table_info('entry')")) {
$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1) ?: [];
foreach (['attributes'] as $column) {
if (!in_array($column, $columns, true)) {
return $this->addColumn($column);
}
}
}
return false;
}
#[\Override]
public function commitNewEntries(): bool {
$sql = <<<'SQL'
DROP TABLE IF EXISTS `tmp`;
CREATE TEMP TABLE `tmp` AS
SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `_entrytmp`
ORDER BY date, id;
INSERT OR IGNORE INTO `_entry`
(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS id,
guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
FROM `tmp`
ORDER BY date, id;
DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);
DROP TABLE IF EXISTS `tmp`;
SQL;
$hadTransaction = $this->pdo->inTransaction();
if (!$hadTransaction) {
$this->pdo->beginTransaction();
}
$result = $this->pdo->exec($sql) !== false;
if (!$result) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
}
if (!$hadTransaction) {
$this->pdo->commit();
}
return $result;
}
/**
* Toggle the read marker on one or more article.
* Then the cache is updated.
*
* @param string|array<string> $ids
* @param bool $is_read
* @return int|false affected rows
*/
#[\Override]
public function markRead($ids, bool $is_read = true) {
if (is_array($ids)) { //Many IDs at once (used by API)
//if (true) { //Speed heuristics //TODO: Not implemented yet for SQLite (so always call IDs one by one)
$affected = 0;
foreach ($ids as $id) {
$affected += ($this->markRead($id, $is_read) ?: 0);
}
return $affected;
//}
} else {
FreshRSS_UserDAO::touch();
$this->pdo->beginTransaction();
$sql = 'UPDATE `_entry` SET is_read=? WHERE id=? AND is_read=?';
$values = [$is_read ? 1 : 0, $ids, $is_read ? 0 : 1];
$stm = $this->pdo->prepare($sql);
if (!($stm && $stm->execute($values))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
$affected = $stm->rowCount();
if ($affected > 0) {
$sql = 'UPDATE `_feed` SET `cache_nbUnreads`=`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
. 'WHERE id=(SELECT e.id_feed FROM `_entry` e WHERE e.id=?)';
$values = [$ids];
$stm = $this->pdo->prepare($sql);
if (!($stm && $stm->execute($values))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
}
$this->pdo->commit();
return $affected;
}
}
/**
* Mark all the articles in a tag as read.
* @param int $id tag ID, or empty for targeting any tag
* @param string $idMax max article ID
* @return int|false affected rows
*/
#[\Override]
public function markReadTag($id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
FreshRSS_UserDAO::touch();
if ($idMax == 0) {
$idMax = time() . '000000';
Minz_Log::debug('Calling markReadTag(0) is deprecated!');
}
$sql = 'UPDATE `_entry` SET is_read = ? WHERE is_read <> ? AND id <= ? AND '
. 'id IN (SELECT et.id_entry FROM `_entrytag` et '
. ($id == 0 ? '' : 'WHERE et.id_tag = ?')
. ')';
$values = [$is_read ? 1 : 0, $is_read ? 1 : 0, $idMax];
if ($id != 0) {
$values[] = $id;
}
[$searchValues, $search] = $this->sqlListEntriesWhere('e.', $filters, $state);
$stm = $this->pdo->prepare($sql . $search);
if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
$affected = $stm->rowCount();
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
return $affected;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Factory.php'
<?php
declare(strict_types=1);
class FreshRSS_Factory {
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createUserDao(?string $username = null): FreshRSS_UserDAO {
return new FreshRSS_UserDAO($username);
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createCategoryDao(?string $username = null): FreshRSS_CategoryDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_CategoryDAOSQLite($username);
default:
return new FreshRSS_CategoryDAO($username);
}
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createFeedDao(?string $username = null): FreshRSS_FeedDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_FeedDAOSQLite($username);
default:
return new FreshRSS_FeedDAO($username);
}
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createEntryDao(?string $username = null): FreshRSS_EntryDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_EntryDAOSQLite($username);
case 'pgsql':
return new FreshRSS_EntryDAOPGSQL($username);
default:
return new FreshRSS_EntryDAO($username);
}
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createTagDao(?string $username = null): FreshRSS_TagDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_TagDAOSQLite($username);
case 'pgsql':
return new FreshRSS_TagDAOPGSQL($username);
default:
return new FreshRSS_TagDAO($username);
}
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createStatsDAO(?string $username = null): FreshRSS_StatsDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_StatsDAOSQLite($username);
case 'pgsql':
return new FreshRSS_StatsDAOPGSQL($username);
default:
return new FreshRSS_StatsDAO($username);
}
}
/**
* @throws Minz_ConfigurationNamespaceException|Minz_PDOConnectionException
*/
public static function createDatabaseDAO(?string $username = null): FreshRSS_DatabaseDAO {
switch (FreshRSS_Context::systemConf()->db['type'] ?? '') {
case 'sqlite':
return new FreshRSS_DatabaseDAOSQLite($username);
case 'pgsql':
return new FreshRSS_DatabaseDAOPGSQL($username);
default:
return new FreshRSS_DatabaseDAO($username);
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Feed.php'
<?php
declare(strict_types=1);
class FreshRSS_Feed extends Minz_Model {
use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
/**
* Normal RSS or Atom feed
* @var int
*/
public const KIND_RSS = 0;
/**
* Invalid RSS or Atom feed
* @var int
*/
public const KIND_RSS_FORCED = 2;
/**
* Normal HTML with XPath scraping
* @var int
*/
public const KIND_HTML_XPATH = 10;
/**
* Normal XML with XPath scraping
* @var int
*/
public const KIND_XML_XPATH = 15;
/**
* Normal JSON with XPath scraping
* @var int
*/
public const KIND_JSON_XPATH = 20;
public const KIND_JSONFEED = 25;
public const KIND_JSON_DOTNOTATION = 30;
public const PRIORITY_IMPORTANT = 20;
public const PRIORITY_MAIN_STREAM = 10;
public const PRIORITY_CATEGORY = 0;
public const PRIORITY_ARCHIVED = -10;
public const TTL_DEFAULT = 0;
public const ARCHIVING_RETENTION_COUNT_LIMIT = 10000;
public const ARCHIVING_RETENTION_PERIOD = 'P3M';
private int $id = 0;
private string $url = '';
private int $kind = 0;
private int $categoryId = 0;
private ?FreshRSS_Category $category = null;
private int $nbEntries = -1;
private int $nbNotRead = -1;
private string $name = '';
private string $website = '';
private string $description = '';
private int $lastUpdate = 0;
private int $priority = self::PRIORITY_MAIN_STREAM;
private string $pathEntries = '';
private string $httpAuth = '';
private bool $error = false;
private int $ttl = self::TTL_DEFAULT;
private bool $mute = false;
private string $hash = '';
private string $lockPath = '';
private string $hubUrl = '';
private string $selfUrl = '';
/**
* @throws FreshRSS_BadUrl_Exception
*/
public function __construct(string $url, bool $validate = true) {
if ($validate) {
$this->_url($url);
} else {
$this->url = $url;
}
}
public static function default(): FreshRSS_Feed {
$f = new FreshRSS_Feed('http://example.net/', false);
$f->faviconPrepare();
return $f;
}
public function id(): int {
return $this->id;
}
public function hash(): string {
if ($this->hash == '') {
$salt = FreshRSS_Context::systemConf()->salt;
$this->hash = hash('crc32b', $salt . $this->url);
}
return $this->hash;
}
public function url(bool $includeCredentials = true): string {
return $includeCredentials ? $this->url : SimplePie_Misc::url_remove_credentials($this->url);
}
public function selfUrl(): string {
return $this->selfUrl;
}
public function kind(): int {
return $this->kind;
}
public function hubUrl(): string {
return $this->hubUrl;
}
public function category(): ?FreshRSS_Category {
if ($this->category === null && $this->categoryId > 0) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$this->category = $catDAO->searchById($this->categoryId);
}
return $this->category;
}
public function categoryId(): int {
if ($this->category !== null) {
return $this->category->id() ?: $this->categoryId;
}
return $this->categoryId;
}
/**
* @return array<FreshRSS_Entry>|null
* @deprecated
*/
public function entries(): ?array {
Minz_Log::warning(__method__ . ' is deprecated since FreshRSS 1.16.1!');
$simplePie = $this->load(false, true);
return $simplePie == null ? [] : iterator_to_array($this->loadEntries($simplePie));
}
public function name(bool $raw = false): string {
return $raw || $this->name != '' ? $this->name : (preg_replace('%^https?://(www[.])?%i', '', $this->url) ?? '');
}
/** @return string HTML-encoded URL of the Web site of the feed */
public function website(): string {
return $this->website;
}
public function description(): string {
return $this->description;
}
public function lastUpdate(): int {
return $this->lastUpdate;
}
public function priority(): int {
return $this->priority;
}
/** @return string HTML-encoded CSS selector */
public function pathEntries(): string {
return $this->pathEntries;
}
/**
* @phpstan-return ($raw is true ? string : array{'username':string,'password':string})
* @return array{'username':string,'password':string}|string
*/
public function httpAuth(bool $raw = true) {
if ($raw) {
return $this->httpAuth;
} else {
$pos_colon = strpos($this->httpAuth, ':');
if ($pos_colon !== false) {
$user = substr($this->httpAuth, 0, $pos_colon);
$pass = substr($this->httpAuth, $pos_colon + 1);
} else {
$user = '';
$pass = '';
}
return [
'username' => $user,
'password' => $pass,
];
}
}
/** @return array<int,mixed> */
public function curlOptions(): array {
$curl_options = [];
if ($this->httpAuth !== '') {
$curl_options[CURLOPT_USERPWD] = htmlspecialchars_decode($this->httpAuth, ENT_QUOTES);
}
return $curl_options;
}
public function inError(): bool {
return $this->error;
}
/**
* @param bool $raw true for database version combined with mute information, false otherwise
*/
public function ttl(bool $raw = false): int {
if ($raw) {
$ttl = $this->ttl;
if ($this->mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) {
$ttl = FreshRSS_Context::userConf()->ttl_default;
}
return $ttl * ($this->mute ? -1 : 1);
}
if ($this->mute && $this->ttl === FreshRSS_Context::userConf()->ttl_default) {
return FreshRSS_Feed::TTL_DEFAULT;
}
return $this->ttl;
}
public function mute(): bool {
return $this->mute;
}
public function nbEntries(): int {
if ($this->nbEntries < 0) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->nbEntries = $feedDAO->countEntries($this->id());
}
return $this->nbEntries;
}
public function nbNotRead(): int {
if ($this->nbNotRead < 0) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->nbNotRead = $feedDAO->countNotRead($this->id());
}
return $this->nbNotRead;
}
public function faviconPrepare(): void {
require_once(LIB_PATH . '/favicons.php');
$url = $this->website;
if ($url == '') {
$url = $this->url;
}
$txt = FAVICONS_DIR . $this->hash() . '.txt';
if (@file_get_contents($txt) !== $url) {
file_put_contents($txt, $url);
}
if (FreshRSS_Context::$isCli) {
$ico = FAVICONS_DIR . $this->hash() . '.ico';
$ico_mtime = @filemtime($ico);
$txt_mtime = @filemtime($txt);
if ($txt_mtime != false &&
($ico_mtime == false || $ico_mtime < $txt_mtime || ($ico_mtime < time() - (14 * 86400)))) {
// no ico file or we should download a new one.
$url = file_get_contents($txt);
if ($url == false || !download_favicon($url, $ico)) {
touch($ico);
}
}
}
}
public static function faviconDelete(string $hash): void {
$path = DATA_PATH . '/favicons/' . $hash;
@unlink($path . '.ico');
@unlink($path . '.txt');
}
public function favicon(): string {
return Minz_Url::display('/f.php?' . $this->hash());
}
public function _id(int $value): void {
$this->id = $value;
}
/**
* @throws FreshRSS_BadUrl_Exception
*/
public function _url(string $value, bool $validate = true): void {
$this->hash = '';
$url = $value;
if ($validate) {
$url = checkUrl($url);
}
if ($url == false) {
throw new FreshRSS_BadUrl_Exception($value);
}
$this->url = $url;
}
public function _kind(int $value): void {
$this->kind = $value;
}
public function _category(?FreshRSS_Category $cat): void {
$this->category = $cat;
$this->categoryId = $this->category == null ? 0 : $this->category->id();
}
/** @param int|string $id */
public function _categoryId($id): void {
$this->category = null;
$this->categoryId = (int)$id;
}
public function _name(string $value): void {
$this->name = $value == '' ? '' : trim($value);
}
public function _website(string $value, bool $validate = true): void {
if ($validate) {
$value = checkUrl($value);
}
if ($value == false) {
$value = '';
}
$this->website = $value;
}
public function _description(string $value): void {
$this->description = $value == '' ? '' : $value;
}
public function _lastUpdate(int $value): void {
$this->lastUpdate = $value;
}
public function _priority(int $value): void {
$this->priority = $value;
}
/** @param string $value HTML-encoded CSS selector */
public function _pathEntries(string $value): void {
$this->pathEntries = $value;
}
public function _httpAuth(string $value): void {
$this->httpAuth = $value;
}
/** @param bool|int $value */
public function _error($value): void {
$this->error = (bool)$value;
}
public function _mute(bool $value): void {
$this->mute = $value;
}
public function _ttl(int $value): void {
$value = min($value, 100_000_000);
$this->ttl = abs($value);
$this->mute = $value < self::TTL_DEFAULT;
}
public function _nbNotRead(int $value): void {
$this->nbNotRead = $value;
}
public function _nbEntries(int $value): void {
$this->nbEntries = $value;
}
/**
* @throws Minz_FileNotExistException
* @throws FreshRSS_Feed_Exception
*/
public function load(bool $loadDetails = false, bool $noCache = false): ?SimplePie {
if ($this->url != '') {
/**
* @throws Minz_FileNotExistException
*/
if (CACHE_PATH == '') {
throw new Minz_FileNotExistException(
'CACHE_PATH',
Minz_Exception::ERROR
);
} else {
$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
$url = htmlspecialchars_decode($this->url, ENT_QUOTES);
if (substr($url, -11) === '#force_feed') {
$simplePie->force_feed(true);
$url = substr($url, 0, -11);
}
$simplePie->set_feed_url($url);
if (!$loadDetails) { //Only activates auto-discovery when adding a new feed
$simplePie->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE);
}
if ($this->attributeBoolean('clear_cache')) {
// Do not use `$simplePie->enable_cache(false);` as it would prevent caching in multiuser context
$this->clearCache();
}
Minz_ExtensionManager::callHook('simplepie_before_init', $simplePie, $this);
$mtime = $simplePie->init();
if ((!$mtime) || $simplePie->error()) {
$errorMessage = $simplePie->error();
if (empty($errorMessage)) {
$errorMessage = '';
} elseif (is_array($errorMessage)) {
$errorMessage = json_encode($errorMessage, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS) ?: '';
}
throw new FreshRSS_Feed_Exception(
($errorMessage == '' ? 'Unknown error for feed' : $errorMessage) .
' [' . $this->url . ']',
$simplePie->status_code()
);
}
$links = $simplePie->get_links('self');
$this->selfUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: '');
$links = $simplePie->get_links('hub');
$this->hubUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: '');
if ($loadDetails) {
// si on a utilisΓ© lβauto-discover, notre url va avoir changΓ©
$subscribe_url = $simplePie->subscribe_url(false) ?? '';
if ($this->name(true) === '') {
//HTML to HTML-PRE //ENT_COMPAT except '&'
$title = strtr(html_only_entity_decode($simplePie->get_title()), ['<' => '<', '>' => '>', '"' => '"']);
$this->_name($title == '' ? $this->url : $title);
}
if ($this->website() === '') {
$this->_website(html_only_entity_decode($simplePie->get_link()));
}
if ($this->description() === '') {
$this->_description(html_only_entity_decode($simplePie->get_description()));
}
} else {
//The case of HTTP 301 Moved Permanently
$subscribe_url = $simplePie->subscribe_url(true) ?? '';
}
$clean_url = SimplePie_Misc::url_remove_credentials($subscribe_url);
if ($subscribe_url !== '' && $subscribe_url !== $url) {
$this->_url($clean_url);
}
if (($mtime === true) || ($mtime > $this->lastUpdate) || $noCache) {
//Minz_Log::debug('FreshRSS no cache ' . $mtime . ' > ' . $this->lastUpdate . ' for ' . $clean_url);
return $simplePie;
}
//Minz_Log::debug('FreshRSS use cache for ' . $clean_url);
}
}
return null;
}
/**
* @return array<string>
*/
public function loadGuids(SimplePie $simplePie): array {
$hasUniqueGuids = true;
$testGuids = [];
$guids = [];
$links = [];
$hadBadGuids = $this->attributeBoolean('hasBadGuids');
$items = $simplePie->get_items();
if (empty($items)) {
return $guids;
}
for ($i = count($items) - 1; $i >= 0; $i--) {
$item = $items[$i];
if ($item == null) {
continue;
}
$guid = safe_ascii($item->get_id(false, false));
$hasUniqueGuids &= empty($testGuids['_' . $guid]);
$testGuids['_' . $guid] = true;
$guids[] = $guid;
$permalink = $item->get_permalink();
if ($permalink != null) {
$links[] = $permalink;
}
}
if ($hadBadGuids != !$hasUniqueGuids) {
if ($hadBadGuids) {
Minz_Log::warning('Feed has invalid GUIDs: ' . $this->url);
} else {
Minz_Log::warning('Feed has valid GUIDs again: ' . $this->url);
}
$feedDAO = FreshRSS_Factory::createFeedDao();
$feedDAO->updateFeedAttribute($this, 'hasBadGuids', !$hasUniqueGuids);
}
return $hasUniqueGuids ? $guids : $links;
}
/** @return Traversable<FreshRSS_Entry> */
public function loadEntries(SimplePie $simplePie): Traversable {
$hasBadGuids = $this->attributeBoolean('hasBadGuids');
$items = $simplePie->get_items();
if (empty($items)) {
return;
}
// We want chronological order and SimplePie uses reverse order.
for ($i = count($items) - 1; $i >= 0; $i--) {
$item = $items[$i];
if ($item == null) {
continue;
}
$title = html_only_entity_decode(strip_tags($item->get_title() ?? ''));
$authors = $item->get_authors();
$link = $item->get_permalink();
$date = @strtotime((string)($item->get_date() ?? '')) ?: 0;
//Tag processing (tag == category)
$categories = $item->get_categories();
$tags = [];
if (is_array($categories)) {
foreach ($categories as $category) {
$text = html_only_entity_decode($category->get_label());
//Some feeds use a single category with comma-separated tags
$labels = explode(',', $text);
if (!empty($labels)) {
foreach ($labels as $label) {
$tags[] = trim($label);
}
}
}
$tags = array_unique($tags);
}
$content = html_only_entity_decode($item->get_content());
$attributeThumbnail = $item->get_thumbnail() ?? [];
if (empty($attributeThumbnail['url'])) {
$attributeThumbnail['url'] = '';
}
$attributeEnclosures = [];
if (!empty($item->get_enclosures())) {
foreach ($item->get_enclosures() as $enclosure) {
$elink = $enclosure->get_link();
if ($elink != '') {
$etitle = $enclosure->get_title() ?? '';
$credits = $enclosure->get_credits() ?? null;
$description = $enclosure->get_description() ?? '';
$mime = strtolower($enclosure->get_type() ?? '');
$medium = strtolower($enclosure->get_medium() ?? '');
$height = $enclosure->get_height();
$width = $enclosure->get_width();
$length = $enclosure->get_length();
$attributeEnclosure = [
'url' => $elink,
];
if ($etitle != '') {
$attributeEnclosure['title'] = $etitle;
}
if (is_array($credits)) {
$attributeEnclosure['credit'] = [];
foreach ($credits as $credit) {
$attributeEnclosure['credit'][] = $credit->get_name();
}
}
if ($description != '') {
$attributeEnclosure['description'] = $description;
}
if ($mime != '') {
$attributeEnclosure['type'] = $mime;
}
if ($medium != '') {
$attributeEnclosure['medium'] = $medium;
}
if ($length != '') {
$attributeEnclosure['length'] = (int)$length;
}
if ($height != '') {
$attributeEnclosure['height'] = (int)$height;
}
if ($width != '') {
$attributeEnclosure['width'] = (int)$width;
}
if (!empty($enclosure->get_thumbnails())) {
foreach ($enclosure->get_thumbnails() as $thumbnail) {
if ($thumbnail !== $attributeThumbnail['url']) {
$attributeEnclosure['thumbnails'][] = $thumbnail;
}
}
}
$attributeEnclosures[] = $attributeEnclosure;
}
}
}
$guid = safe_ascii($item->get_id(false, false));
unset($item);
$authorNames = '';
if (is_array($authors)) {
foreach ($authors as $author) {
$authorName = $author->name != '' ? $author->name : $author->email;
if ($authorName != '') {
$authorNames .= escapeToUnicodeAlternative(strip_tags($authorName), true) . '; ';
}
}
}
$authorNames = substr($authorNames, 0, -2) ?: '';
$entry = new FreshRSS_Entry(
$this->id(),
$hasBadGuids ? '' : $guid,
$title == '' ? '' : $title,
$authorNames,
$content == '' ? '' : $content,
$link == null ? '' : $link,
$date ?: time()
);
$entry->_tags($tags);
$entry->_feed($this);
if (!empty($attributeThumbnail['url'])) {
$entry->_attribute('thumbnail', $attributeThumbnail);
}
$entry->_attribute('enclosures', $attributeEnclosures);
$entry->hash(); //Must be computed before loading full content
$entry->loadCompleteContent(); // Optionally load full content for truncated feeds
yield $entry;
}
}
/**
* Given a feed content generated from a FreshRSS_View
* returns a SimplePie initialized already with that content
* @param string $feedContent the content of the feed, typically generated via FreshRSS_View::renderToString()
*/
private function simplePieFromContent(string $feedContent): SimplePie {
$simplePie = customSimplePie();
$simplePie->set_raw_data($feedContent);
$simplePie->init();
return $simplePie;
}
/** @return array<string,string> */
private function dotNotationForStandardJsonFeed(): array {
return [
'feedTitle' => 'title',
'item' => 'items',
'itemTitle' => 'title',
'itemContent' => 'content_text',
'itemContentHTML' => 'content_html',
'itemUri' => 'url',
'itemTimestamp' => 'date_published',
'itemTimeFormat' => DateTimeInterface::RFC3339_EXTENDED,
'itemThumbnail' => 'image',
'itemCategories' => 'tags',
'itemUid' => 'id',
'itemAttachment' => 'attachments',
'itemAttachmentUrl' => 'url',
'itemAttachmentType' => 'mime_type',
'itemAttachmentLength' => 'size_in_bytes',
];
}
public function loadJson(): ?SimplePie {
if ($this->url == '') {
return null;
}
$feedSourceUrl = htmlspecialchars_decode($this->url, ENT_QUOTES);
if ($feedSourceUrl == null) {
return null;
}
$httpAccept = 'json';
$json = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions());
if (strlen($json) <= 0) {
return null;
}
//check if the content is actual JSON
$jf = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($jf)) {
return null;
}
/** @var array<string,string> $json_dotnotation */
$json_dotnotation = $this->attributeArray('json_dotnotation') ?? [];
$dotnotations = $this->kind() === FreshRSS_Feed::KIND_JSONFEED ? $this->dotNotationForStandardJsonFeed() : $json_dotnotation;
$feedContent = FreshRSS_dotNotation_Util::convertJsonToRss($jf, $feedSourceUrl, $dotnotations, $this->name());
if ($feedContent == null) {
return null;
}
return $this->simplePieFromContent($feedContent);
}
public function loadHtmlXpath(): ?SimplePie {
if ($this->url == '') {
return null;
}
$feedSourceUrl = htmlspecialchars_decode($this->url, ENT_QUOTES);
if ($feedSourceUrl == null) {
return null;
}
// Same naming conventions than https://rss-bridge.github.io/rss-bridge/Bridge_API/XPathAbstract.html
// https://rss-bridge.github.io/rss-bridge/Bridge_API/BridgeAbstract.html#collectdata
/** @var array<string,string> $xPathSettings */
$xPathSettings = $this->attributeArray('xpath');
$xPathFeedTitle = $xPathSettings['feedTitle'] ?? '';
$xPathItem = $xPathSettings['item'] ?? '';
$xPathItemTitle = $xPathSettings['itemTitle'] ?? '';
$xPathItemContent = $xPathSettings['itemContent'] ?? '';
$xPathItemUri = $xPathSettings['itemUri'] ?? '';
$xPathItemAuthor = $xPathSettings['itemAuthor'] ?? '';
$xPathItemTimestamp = $xPathSettings['itemTimestamp'] ?? '';
$xPathItemTimeFormat = $xPathSettings['itemTimeFormat'] ?? '';
$xPathItemThumbnail = $xPathSettings['itemThumbnail'] ?? '';
$xPathItemCategories = $xPathSettings['itemCategories'] ?? '';
$xPathItemUid = $xPathSettings['itemUid'] ?? '';
if ($xPathItem == '') {
return null;
}
$httpAccept = $this->kind() === FreshRSS_Feed::KIND_XML_XPATH ? 'xml' : 'html';
$html = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions());
if (strlen($html) <= 0) {
return null;
}
$view = new FreshRSS_View();
$view->_path('index/rss.phtml');
$view->internal_rendering = true;
$view->rss_url = htmlspecialchars($feedSourceUrl, ENT_COMPAT, 'UTF-8');
$view->html_url = $view->rss_url;
$view->entries = [];
try {
$doc = new DOMDocument();
$doc->recover = true;
$doc->strictErrorChecking = false;
$ok = false;
switch ($this->kind()) {
case FreshRSS_Feed::KIND_HTML_XPATH:
$ok = $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING) !== false;
break;
case FreshRSS_Feed::KIND_XML_XPATH:
$ok = $doc->loadXML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING) !== false;
break;
}
if (!$ok) {
return null;
}
$xpath = new DOMXPath($doc);
$xpathEvaluateString = function (string $expression, ?DOMNode $contextNode = null) use ($xpath): string {
$result = @$xpath->evaluate('normalize-space(' . $expression . ')', $contextNode);
return is_string($result) ? $result : '';
};
$view->rss_title = $xPathFeedTitle == '' ? $this->name() :
htmlspecialchars($xpathEvaluateString($xPathFeedTitle), ENT_COMPAT, 'UTF-8');
$view->rss_base = htmlspecialchars(trim($xpathEvaluateString('//base/@href')), ENT_COMPAT, 'UTF-8');
$nodes = $xpath->query($xPathItem);
if ($nodes === false || $nodes->length === 0) {
return null;
}
foreach ($nodes as $node) {
$item = [];
$item['title'] = $xPathItemTitle == '' ? '' : $xpathEvaluateString($xPathItemTitle, $node);
$item['content'] = '';
if ($xPathItemContent != '') {
$result = @$xpath->evaluate($xPathItemContent, $node);
if ($result instanceof DOMNodeList) {
// List of nodes, save as HTML
$content = '';
foreach ($result as $child) {
$content .= $doc->saveHTML($child) . "\n";
}
$item['content'] = $content;
} elseif (is_string($result) || is_int($result) || is_bool($result)) {
// Typed expression, save as-is
$item['content'] = (string)$result;
}
}
$item['link'] = $xPathItemUri == '' ? '' : $xpathEvaluateString($xPathItemUri, $node);
$item['author'] = $xPathItemAuthor == '' ? '' : $xpathEvaluateString($xPathItemAuthor, $node);
$item['timestamp'] = $xPathItemTimestamp == '' ? '' : $xpathEvaluateString($xPathItemTimestamp, $node);
if ($xPathItemTimeFormat != '') {
$dateTime = DateTime::createFromFormat($xPathItemTimeFormat, $item['timestamp']);
if ($dateTime != false) {
$item['timestamp'] = $dateTime->format(DateTime::ATOM);
}
}
$item['thumbnail'] = $xPathItemThumbnail == '' ? '' : $xpathEvaluateString($xPathItemThumbnail, $node);
if ($xPathItemCategories != '') {
$itemCategories = @$xpath->evaluate($xPathItemCategories, $node);
if (is_string($itemCategories) && $itemCategories !== '') {
$item['tags'] = [$itemCategories];
} elseif ($itemCategories instanceof DOMNodeList && $itemCategories->length > 0) {
$item['tags'] = [];
foreach ($itemCategories as $itemCategory) {
$item['tags'][] = $itemCategory->textContent;
}
}
}
if ($xPathItemUid != '') {
$item['guid'] = $xpathEvaluateString($xPathItemUid, $node);
}
if (empty($item['guid'])) {
$item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']);
}
if ($item['title'] != '' || $item['content'] != '' || $item['link'] != '') {
// HTML-encoding/escaping of the relevant fields (all except 'content')
foreach (['author', 'guid', 'link', 'thumbnail', 'timestamp', 'tags', 'title'] as $key) {
if (!empty($item[$key]) && is_string($item[$key])) {
$item[$key] = Minz_Helper::htmlspecialchars_utf8($item[$key]);
}
}
// CDATA protection
$item['content'] = str_replace(']]>', ']]>', $item['content']);
$view->entries[] = FreshRSS_Entry::fromArray($item);
}
}
} catch (Exception $ex) {
Minz_Log::warning($ex->getMessage());
return null;
}
return $this->simplePieFromContent($view->renderToString());
}
/**
* @return int|null The max number of unread articles to keep, or null if disabled.
*/
public function keepMaxUnread() {
$keepMaxUnread = $this->attributeInt('keep_max_n_unread');
if ($keepMaxUnread === null) {
$keepMaxUnread = FreshRSS_Context::userConf()->mark_when['max_n_unread'];
}
return is_int($keepMaxUnread) && $keepMaxUnread >= 0 ? $keepMaxUnread : null;
}
/**
* @return int|false The number of articles marked as read, of false if error
*/
public function markAsReadMaxUnread() {
$keepMaxUnread = $this->keepMaxUnread();
if ($keepMaxUnread === null) {
return false;
}
$feedDAO = FreshRSS_Factory::createFeedDao();
$affected = $feedDAO->markAsReadMaxUnread($this->id(), $keepMaxUnread);
return $affected;
}
/**
* Applies the *mark as read upon gone* policy, if enabled.
* Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after.
* @return int|false the number of lines affected, or false if not applicable
*/
public function markAsReadUponGone(bool $upstreamIsEmpty, int $minLastSeen = 0) {
$readUponGone = $this->attributeBoolean('read_upon_gone');
if ($readUponGone === null) {
$readUponGone = FreshRSS_Context::userConf()->mark_when['gone'];
}
if (!$readUponGone) {
return false;
}
if ($upstreamIsEmpty) {
if ($minLastSeen <= 0) {
$minLastSeen = time();
}
$entryDAO = FreshRSS_Factory::createEntryDao();
$affected = $entryDAO->markReadFeed($this->id(), $minLastSeen . '000000');
} else {
$feedDAO = FreshRSS_Factory::createFeedDao();
$affected = $feedDAO->markAsReadNotSeen($this->id(), $minLastSeen);
}
if ($affected > 0) {
Minz_Log::debug(__METHOD__ . " $affected items" . ($upstreamIsEmpty ? ' (all)' : '') . ' [' . $this->url(false) . ']');
}
return $affected;
}
/**
* Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after
* @return int|false
*/
public function cleanOldEntries() {
/** @var array<string,bool|int|string>|null $archiving */
$archiving = $this->attributeArray('archiving');
if ($archiving === null) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$category = $catDAO->searchById($this->categoryId);
$archiving = $category === null ? null : $category->attributeArray('archiving');
/** @var array<string,bool|int|string>|null $archiving */
if ($archiving === null) {
$archiving = FreshRSS_Context::userConf()->archiving;
}
}
if (is_array($archiving)) {
$entryDAO = FreshRSS_Factory::createEntryDao();
$nb = $entryDAO->cleanOldEntries($this->id(), $archiving);
if ($nb > 0) {
Minz_Log::debug($nb . ' entries cleaned in feed [' . $this->url(false) . '] with: ' . json_encode($archiving));
}
return $nb;
}
return false;
}
/**
* @param string $url Overridden URL. Will default to the feed URL.
* @throws FreshRSS_Context_Exception
*/
public function cacheFilename(string $url = ''): string {
$simplePie = customSimplePie($this->attributes(), $this->curlOptions());
if ($url !== '') {
$filename = $simplePie->get_cache_filename($url);
return CACHE_PATH . '/' . $filename . '.html';
}
$url = htmlspecialchars_decode($this->url);
$filename = $simplePie->get_cache_filename($url);
if ($this->kind === FreshRSS_Feed::KIND_HTML_XPATH) {
return CACHE_PATH . '/' . $filename . '.html';
} elseif ($this->kind === FreshRSS_Feed::KIND_XML_XPATH) {
return CACHE_PATH . '/' . $filename . '.xml';
} else {
return CACHE_PATH . '/' . $filename . '.spc';
}
}
public function clearCache(): bool {
return @unlink($this->cacheFilename());
}
/** @return int|false */
public function cacheModifiedTime() {
$filename = $this->cacheFilename();
clearstatcache(true, $filename);
return @filemtime($filename);
}
public function lock(): bool {
$this->lockPath = TMP_PATH . '/' . $this->hash() . '.freshrss.lock';
if (file_exists($this->lockPath) && ((time() - (@filemtime($this->lockPath) ?: 0)) > 3600)) {
@unlink($this->lockPath);
}
if (($handle = @fopen($this->lockPath, 'x')) === false) {
return false;
}
//register_shutdown_function('unlink', $this->lockPath);
@fclose($handle);
return true;
}
public function unlock(): bool {
return @unlink($this->lockPath);
}
//<WebSub>
public function pubSubHubbubEnabled(): bool {
$url = $this->selfUrl ?: $this->url;
$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
if ($hubFile = @file_get_contents($hubFilename)) {
$hubJson = json_decode($hubFile, true);
if (is_array($hubJson) && empty($hubJson['error']) &&
(empty($hubJson['lease_end']) || $hubJson['lease_end'] > time())) {
return true;
}
}
return false;
}
public function pubSubHubbubError(bool $error = true): bool {
$url = $this->selfUrl ?: $this->url;
$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
$hubFile = @file_get_contents($hubFilename);
$hubJson = is_string($hubFile) ? json_decode($hubFile, true) : null;
if (is_array($hubJson) && (!isset($hubJson['error']) || $hubJson['error'] !== $error)) {
$hubJson['error'] = $error;
file_put_contents($hubFilename, json_encode($hubJson));
Minz_Log::warning('Set error to ' . ($error ? 1 : 0) . ' for ' . $url, PSHB_LOG);
}
return false;
}
/**
* @return string|false
*/
public function pubSubHubbubPrepare() {
$key = '';
if (Minz_Request::serverIsPublic(FreshRSS_Context::systemConf()->base_url) &&
$this->hubUrl && $this->selfUrl && @is_dir(PSHB_PATH)) {
$path = PSHB_PATH . '/feeds/' . sha1($this->selfUrl);
$hubFilename = $path . '/!hub.json';
if ($hubFile = @file_get_contents($hubFilename)) {
$hubJson = json_decode($hubFile, true);
if (!is_array($hubJson) || empty($hubJson['key']) || !ctype_xdigit($hubJson['key'])) {
$text = 'Invalid JSON for WebSub: ' . $this->url;
Minz_Log::warning($text);
Minz_Log::warning($text, PSHB_LOG);
return false;
}
if ((!empty($hubJson['lease_end'])) && ($hubJson['lease_end'] < (time() + (3600 * 23)))) { //TODO: Make a better policy
$text = 'WebSub lease ends at '
. date('c', empty($hubJson['lease_end']) ? time() : $hubJson['lease_end'])
. ' and needs renewal: ' . $this->url;
Minz_Log::warning($text);
Minz_Log::warning($text, PSHB_LOG);
$key = $hubJson['key']; //To renew our lease
} elseif (((!empty($hubJson['error'])) || empty($hubJson['lease_end'])) &&
(empty($hubJson['lease_start']) || $hubJson['lease_start'] < time() - (3600 * 23))) { //Do not renew too often
$key = $hubJson['key']; //To renew our lease
}
} else {
@mkdir($path, 0770, true);
$key = sha1($path . FreshRSS_Context::systemConf()->salt);
$hubJson = [
'hub' => $this->hubUrl,
'key' => $key,
];
file_put_contents($hubFilename, json_encode($hubJson));
@mkdir(PSHB_PATH . '/keys/', 0770, true);
file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl);
$text = 'WebSub prepared for ' . $this->url;
Minz_Log::debug($text);
Minz_Log::debug($text, PSHB_LOG);
}
$currentUser = Minz_User::name() ?? '';
if (FreshRSS_user_Controller::checkUsername($currentUser) && !file_exists($path . '/' . $currentUser . '.txt')) {
touch($path . '/' . $currentUser . '.txt');
}
}
return $key;
}
//Parameter true to subscribe, false to unsubscribe.
public function pubSubHubbubSubscribe(bool $state): bool {
if ($state) {
$url = $this->selfUrl ?: $this->url;
} else {
$url = $this->url; //Always use current URL during unsubscribe
}
if ($url && (Minz_Request::serverIsPublic(FreshRSS_Context::systemConf()->base_url) || !$state)) {
$hubFilename = PSHB_PATH . '/feeds/' . sha1($url) . '/!hub.json';
$hubFile = @file_get_contents($hubFilename);
if ($hubFile === false) {
Minz_Log::warning('JSON not found for WebSub: ' . $this->url);
return false;
}
$hubJson = json_decode($hubFile, true);
if (!is_array($hubJson) || empty($hubJson['key']) || !ctype_xdigit($hubJson['key']) || empty($hubJson['hub'])) {
Minz_Log::warning('Invalid JSON for WebSub: ' . $this->url);
return false;
}
$callbackUrl = checkUrl(Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']);
if ($callbackUrl == '') {
Minz_Log::warning('Invalid callback for WebSub: ' . $this->url);
return false;
}
if (!$state) { //unsubscribe
$hubJson['lease_end'] = time() - 60;
file_put_contents($hubFilename, json_encode($hubJson));
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $hubJson['hub'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'hub.verify' => 'sync',
'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
'hub.topic' => $url,
'hub.callback' => $callbackUrl,
]),
CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
CURLOPT_MAXREDIRS => 10,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_ENCODING => '', //Enable all encodings
//CURLOPT_VERBOSE => 1, // To debug sent HTTP headers
]);
$response = curl_exec($ch);
$info = curl_getinfo($ch);
Minz_Log::warning('WebSub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $url .
' via hub ' . $hubJson['hub'] .
' with callback ' . $callbackUrl . ': ' . $info['http_code'] . ' ' . $response, PSHB_LOG);
if (substr('' . $info['http_code'], 0, 1) == '2') {
return true;
} else {
$hubJson['lease_start'] = time(); //Prevent trying again too soon
$hubJson['error'] = true;
file_put_contents($hubFilename, json_encode($hubJson));
return false;
}
}
return false;
}
//</WebSub>
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/FeedDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_FeedDAO extends Minz_ModelPdo {
protected function addColumn(string $name): bool {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
Minz_Log::warning(__method__ . ': ' . $name);
try {
if ($name === 'kind') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
}
} catch (Exception $e) {
Minz_Log::error(__method__ . ' error: ' . $e->getMessage());
}
return false;
}
/** @param array<int|string> $errorInfo */
protected function autoUpdateDb(array $errorInfo): bool {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", (string)$errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['kind'] as $column) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
}
}
return false;
}
/**
* @param array{'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string|array<string|mixed>} $valuesTmp
* @return int|false
*/
public function addFeed(array $valuesTmp) {
$sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$stm = $this->pdo->prepare($sql);
$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
if (!isset($valuesTmp['pathEntries'])) {
$valuesTmp['pathEntries'] = '';
}
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$values = [
$valuesTmp['url'],
$valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS,
$valuesTmp['category'],
mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
$valuesTmp['website'],
sanitizeHTML($valuesTmp['description'], ''),
$valuesTmp['lastUpdate'],
isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM,
mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'),
base64_encode($valuesTmp['httpAuth']),
isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0,
isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT,
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
];
if ($stm !== false && $stm->execute($values)) {
$feedId = $this->pdo->lastInsertId('`_feed_id_seq`');
return $feedId === false ? false : (int)$feedId;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->addFeed($valuesTmp);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function addFeedObject(FreshRSS_Feed $feed) {
// Add feed only if we donβt find it in DB
$feed_search = $this->searchByUrl($feed->url());
if (!$feed_search) {
$values = [
'id' => $feed->id(),
'url' => $feed->url(),
'kind' => $feed->kind(),
'category' => $feed->categoryId(),
'name' => $feed->name(true),
'website' => $feed->website(),
'description' => $feed->description(),
'lastUpdate' => 0,
'error' => false,
'pathEntries' => $feed->pathEntries(),
'httpAuth' => $feed->httpAuth(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
$id = $this->addFeed($values);
if ($id) {
$feed->_id($id);
$feed->faviconPrepare();
}
return $id;
} else {
// The feed already exists so make sure it is not muted
$feed->_ttl($feed_search->ttl());
$feed->_mute(false);
// Merge existing and import attributes
$existingAttributes = $feed_search->attributes();
$importAttributes = $feed->attributes();
$feed->_attributes(array_replace_recursive($existingAttributes, $importAttributes));
// Update some values of the existing feed using the import
$values = [
'kind' => $feed->kind(),
'name' => $feed->name(true),
'website' => $feed->website(),
'description' => $feed->description(),
'pathEntries' => $feed->pathEntries(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
if (!$this->updateFeed($feed_search->id(), $values)) {
return false;
}
return $feed_search->id();
}
}
/**
* @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} $valuesTmp $valuesTmp
* @return int|false
*/
public function updateFeed(int $id, array $valuesTmp) {
$values = [];
$originalValues = $valuesTmp;
if (isset($valuesTmp['name'])) {
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
}
if (isset($valuesTmp['url'])) {
$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
}
if (isset($valuesTmp['website'])) {
$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
}
$set = '';
foreach ($valuesTmp as $key => $v) {
$set .= '`' . $key . '`=?, ';
if ($key === 'httpAuth') {
$valuesTmp[$key] = base64_encode($v);
} elseif ($key === 'attributes') {
$valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES);
}
}
$set = substr($set, 0, -2);
$sql = 'UPDATE `_feed` SET ' . $set . ' WHERE id=?';
$stm = $this->pdo->prepare($sql);
foreach ($valuesTmp as $v) {
$values[] = $v;
}
$values[] = $id;
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->updateFeed($id, $originalValues);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' for feed ' . $id);
return false;
}
}
/**
* @param non-empty-string $key
* @param string|array<mixed>|bool|int|null $value
* @return int|false
*/
public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value) {
$feed->_attribute($key, $value);
return $this->updateFeed(
$feed->id(),
['attributes' => $feed->attributes()]
);
}
/**
* @return int|false
* @see updateCachedValues()
*/
public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
$sql = 'UPDATE `_feed` SET `lastUpdate`=?, error=? WHERE id=?';
$values = [
$mtime <= 0 ? time() : $mtime,
$inError ? 1 : 0,
$id,
];
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
/** @return int|false */
public function mute(int $id, bool $value = true) {
$sql = 'UPDATE `_feed` SET ttl=' . ($value ? '-' : '') . 'ABS(ttl) WHERE id=' . intval($id);
return $this->pdo->exec($sql);
}
/** @return int|false */
public function changeCategory(int $idOldCat, int $idNewCat) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$newCat = $catDAO->searchById($idNewCat);
if ($newCat === null) {
$newCat = $catDAO->getDefault();
}
if ($newCat === null) {
return false;
}
$sql = 'UPDATE `_feed` SET category=? WHERE category=?';
$stm = $this->pdo->prepare($sql);
$values = [
$newCat->id(),
$idOldCat,
];
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function deleteFeed(int $id) {
$sql = 'DELETE FROM `_feed` WHERE id=?';
$stm = $this->pdo->prepare($sql);
$values = [$id];
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* @param bool|null $muted to include only muted feeds
* @return int|false
*/
public function deleteFeedByCategory(int $id, ?bool $muted = null) {
$sql = 'DELETE FROM `_feed` WHERE category=?';
if ($muted) {
$sql .= ' AND ttl < 0';
}
$stm = $this->pdo->prepare($sql);
$values = [$id];
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return Traversable<array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string}> */
public function selectAll(): Traversable {
$sql = <<<'SQL'
SELECT id, url, kind, category, name, website, description, `lastUpdate`,
priority, `pathEntries`, `httpAuth`, error, ttl, attributes
FROM `_feed`
SQL;
$stm = $this->pdo->query($sql);
if ($stm !== false) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
/** @var array{'id':int,'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string} $row */
yield $row;
}
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
yield from $this->selectAll();
} else {
Minz_Log::error(__method__ . ' error: ' . json_encode($info));
}
}
}
public function searchById(int $id): ?FreshRSS_Feed {
$sql = 'SELECT * FROM `_feed` WHERE id=:id';
$res = $this->fetchAssoc($sql, [':id' => $id]);
if ($res == null) {
return null;
}
/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
* 'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
$feeds = self::daoToFeeds($res);
return $feeds[$id] ?? null;
}
public function searchByUrl(string $url): ?FreshRSS_Feed {
$sql = 'SELECT * FROM `_feed` WHERE url=:url';
$res = $this->fetchAssoc($sql, [':url' => $url]);
/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
* 'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
return empty($res[0]) ? null : (current(self::daoToFeeds($res)) ?: null);
}
/** @return array<int> */
public function listFeedsIds(): array {
$sql = 'SELECT id FROM `_feed`';
/** @var array<int> $res */
$res = $this->fetchColumn($sql, 0) ?? [];
return $res;
}
/**
* @return array<int,FreshRSS_Feed>
*/
public function listFeeds(): array {
$sql = 'SELECT * FROM `_feed` ORDER BY name';
$res = $this->fetchAssoc($sql);
/** @var array<array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
* 'priority':int,'pathEntries':string,'httpAuth':string,'error':int,'ttl':int,'attributes':string}>|null $res */
return $res == null ? [] : self::daoToFeeds($res);
}
/** @return array<string,string> */
public function listFeedsNewestItemUsec(?int $id_feed = null): array {
$sql = 'SELECT id_feed, MAX(id) as newest_item_us FROM `_entry` ';
if ($id_feed === null) {
$sql .= 'GROUP BY id_feed';
} else {
$sql .= 'WHERE id_feed=' . intval($id_feed);
}
$res = $this->fetchAssoc($sql);
/** @var array<array{'id_feed':int,'newest_item_us':string}>|null $res */
if ($res == null) {
return [];
}
$newestItemUsec = [];
foreach ($res as $line) {
$newestItemUsec['f_' . $line['id_feed']] = $line['newest_item_us'];
}
return $newestItemUsec;
}
/**
* @param int $defaultCacheDuration Use -1 to return all feeds, without filtering them by TTL.
* @return array<int,FreshRSS_Feed>
*/
public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
$sql = 'SELECT id, url, kind, category, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes, `cache_nbEntries`, `cache_nbUnreads` '
. 'FROM `_feed` '
. ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
. ' AND `lastUpdate` < (' . (time() + 60)
. '-(CASE WHEN ttl=' . FreshRSS_Feed::TTL_DEFAULT . ' THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
. 'ORDER BY `lastUpdate` '
. ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
$stm = $this->pdo->query($sql);
if ($stm !== false) {
return self::daoToFeeds($stm->fetchAll(PDO::FETCH_ASSOC));
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return [];
}
}
/** @return array<int,string> */
public function listTitles(int $id, int $limit = 0): array {
$sql = 'SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC'
. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
$res = $this->fetchColumn($sql, 0, [':id_feed' => $id]) ?? [];
/** @var array<int,string> $res */
return $res;
}
/**
* @param bool|null $muted to include only muted feeds
* @return array<int,FreshRSS_Feed>
*/
public function listByCategory(int $cat, ?bool $muted = null): array {
$sql = 'SELECT * FROM `_feed` WHERE category=:category';
if ($muted) {
$sql .= ' AND ttl < 0';
}
$res = $this->fetchAssoc($sql, [':category' => $cat]);
if ($res == null) {
return [];
}
/**
* @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
* 'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res
*/
$feeds = self::daoToFeeds($res);
uasort($feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
return strnatcasecmp($a->name(), $b->name());
});
return $feeds;
}
public function countEntries(int $id): int {
$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed';
$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
return isset($res[0]) ? (int)($res[0]) : -1;
}
public function countNotRead(int $id): int {
$sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0';
$res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
return isset($res[0]) ? (int)($res[0]) : -1;
}
/**
* Update cached values for selected feeds, or all feeds if no feed ID is provided.
* @return int|false
*/
public function updateCachedValues(int ...$feedIds) {
//2 sub-requests with FOREIGN KEY(e.id_feed), INDEX(e.is_read) faster than 1 request with GROUP BY or CASE
$sql = <<<SQL
UPDATE `_feed`
SET `cache_nbEntries`=(SELECT COUNT(e1.id) FROM `_entry` e1 WHERE e1.id_feed=`_feed`.id),
`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `_entry` e2 WHERE e2.id_feed=`_feed`.id AND e2.is_read=0)
SQL;
if (count($feedIds) > 0) {
$sql .= ' WHERE id IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
}
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($feedIds)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Remember to call updateCachedValues() after calling this function
* @return int|false number of lines affected or false in case of error
*/
public function markAsReadMaxUnread(int $id, int $n) {
//Double SELECT for MySQL workaround ERROR 1093 (HY000)
$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
SELECT e2.id FROM `_entry` e2
WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
ORDER BY e2.id DESC
LIMIT 1
OFFSET :limit) e3)
SQL;
if (($stm = $this->pdo->prepare($sql)) &&
$stm->bindParam(':id_feed1', $id, PDO::PARAM_INT) &&
$stm->bindParam(':id_feed2', $id, PDO::PARAM_INT) &&
$stm->bindParam(':limit', $n, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Remember to call updateCachedValues() after calling this function
* @return int|false number of lines affected or false in case of error
*/
public function markAsReadNotSeen(int $id, int $minLastSeen) {
$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
SQL;
if (($stm = $this->pdo->prepare($sql)) &&
$stm->bindValue(':id_feed', $id, PDO::PARAM_INT) &&
$stm->bindValue(':min_last_seen', $minLastSeen, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* @return int|false
*/
public function truncate(int $id) {
$sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
$stm = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
if (!($stm !== false &&
$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
$stm->execute())) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
$this->pdo->rollBack();
return false;
}
$affected = $stm->rowCount();
$sql = 'UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id';
$stm = $this->pdo->prepare($sql);
if (!($stm !== false &&
$stm->bindParam(':id', $id, PDO::PARAM_INT) &&
$stm->execute())) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
$this->pdo->rollBack();
return false;
}
$this->pdo->commit();
return $affected;
}
public function purge(): bool {
$sql = 'DELETE FROM `_entry`';
$stm = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
if (!($stm && $stm->execute())) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
$sql = 'UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0';
$stm = $this->pdo->prepare($sql);
if (!($stm && $stm->execute())) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
return $this->pdo->commit();
}
/**
* @param array<int,array{'id'?:int,'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth'?:string,'error'?:int|bool,'ttl'?:int,'attributes'?:string,'cache_nbUnreads'?:int,'cache_nbEntries'?:int}> $listDAO
* @return array<int,FreshRSS_Feed>
*/
public static function daoToFeeds(array $listDAO, ?int $catID = null): array {
$list = [];
foreach ($listDAO as $key => $dao) {
FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'category', 'lastUpdate', 'priority', 'error', 'ttl', 'cache_nbUnreads', 'cache_nbEntries']);
if (!isset($dao['name'])) {
continue;
}
if (isset($dao['id'])) {
$key = (int)$dao['id'];
}
if ($catID === null) {
$category = $dao['category'] ?? 0;
} else {
$category = $catID;
}
$myFeed = new FreshRSS_Feed($dao['url'] ?? '', false);
$myFeed->_kind($dao['kind'] ?? FreshRSS_Feed::KIND_RSS);
$myFeed->_categoryId($category);
$myFeed->_name($dao['name']);
$myFeed->_website($dao['website'] ?? '', false);
$myFeed->_description($dao['description'] ?? '');
$myFeed->_lastUpdate($dao['lastUpdate'] ?? 0);
$myFeed->_priority($dao['priority'] ?? 10);
$myFeed->_pathEntries($dao['pathEntries'] ?? '');
$myFeed->_httpAuth(base64_decode($dao['httpAuth'] ?? '', true) ?: '');
$myFeed->_error($dao['error'] ?? 0);
$myFeed->_ttl($dao['ttl'] ?? FreshRSS_Feed::TTL_DEFAULT);
$myFeed->_attributes($dao['attributes'] ?? '');
$myFeed->_nbNotRead($dao['cache_nbUnreads'] ?? -1);
$myFeed->_nbEntries($dao['cache_nbEntries'] ?? -1);
if (isset($dao['id'])) {
$myFeed->_id($dao['id']);
}
$list[$key] = $myFeed;
}
return $list;
}
public function count(): int {
$sql = 'SELECT COUNT(e.id) AS count FROM `_feed` e';
$stm = $this->pdo->query($sql);
if ($stm == false) {
return -1;
}
$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
return (int)($res[0] ?? 0);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/FeedDAOSQLite.php'
<?php
declare(strict_types=1);
class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAO {
/** @param array<int|string> $errorInfo */
#[\Override]
protected function autoUpdateDb(array $errorInfo): bool {
if ($tableInfo = $this->pdo->query("PRAGMA table_info('feed')")) {
$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
foreach (['attributes', 'kind'] as $column) {
if (!in_array($column, $columns, true)) {
return $this->addColumn($column);
}
}
}
return false;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/FilterAction.php'
<?php
declare(strict_types=1);
class FreshRSS_FilterAction {
private FreshRSS_BooleanSearch $booleanSearch;
/** @var array<string>|null */
private ?array $actions = null;
/** @param array<string> $actions */
private function __construct(FreshRSS_BooleanSearch $booleanSearch, array $actions) {
$this->booleanSearch = $booleanSearch;
$this->_actions($actions);
}
public function booleanSearch(): FreshRSS_BooleanSearch {
return $this->booleanSearch;
}
/** @return array<string> */
public function actions(): array {
return $this->actions ?? [];
}
/** @param array<string> $actions */
public function _actions(?array $actions): void {
if (is_array($actions)) {
$this->actions = array_unique($actions);
} else {
$this->actions = null;
}
}
/** @return array{'search'?:string,'actions'?:array<string>} */
public function toJSON(): array {
if (is_array($this->actions) && $this->booleanSearch != null) {
return [
'search' => $this->booleanSearch->getRawInput(),
'actions' => $this->actions,
];
}
return [];
}
/** @param array|mixed|null $json */
public static function fromJSON($json): ?FreshRSS_FilterAction {
if (is_array($json) && !empty($json['search']) && !empty($json['actions']) && is_array($json['actions'])) {
return new FreshRSS_FilterAction(new FreshRSS_BooleanSearch($json['search']), $json['actions']);
}
return null;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/FilterActionsTrait.php'
<?php
declare(strict_types=1);
/**
* Logic to apply filter actions (for feeds, categories, user configuration...).
*/
trait FreshRSS_FilterActionsTrait {
/** @var array<FreshRSS_FilterAction>|null $filterActions */
private ?array $filterActions = null;
/**
* @return array<FreshRSS_FilterAction>
*/
private function filterActions(): array {
if (empty($this->filterActions)) {
$this->filterActions = [];
$filters = $this->attributeArray('filters') ?? [];
foreach ($filters as $filter) {
$filterAction = FreshRSS_FilterAction::fromJSON($filter);
if ($filterAction != null) {
$this->filterActions[] = $filterAction;
}
}
}
return $this->filterActions;
}
/**
* @param array<FreshRSS_FilterAction>|null $filterActions
*/
private function _filterActions(?array $filterActions): void {
$this->filterActions = $filterActions;
if ($this->filterActions !== null && !empty($this->filterActions)) {
$this->_attribute('filters', array_map(
static fn(?FreshRSS_FilterAction $af) => $af == null ? null : $af->toJSON(),
$this->filterActions));
} else {
$this->_attribute('filters', null);
}
}
/** @return array<FreshRSS_BooleanSearch> */
public function filtersAction(string $action): array {
$action = trim($action);
if ($action == '') {
return [];
}
$filters = [];
$filterActions = $this->filterActions();
for ($i = count($filterActions) - 1; $i >= 0; $i--) {
$filterAction = $filterActions[$i];
if (in_array($action, $filterAction->actions(), true)) {
$filters[] = $filterAction->booleanSearch();
}
}
return $filters;
}
/**
* @param array<string> $filters
*/
public function _filtersAction(string $action, array $filters): void {
$action = trim($action);
if ($action === '') {
return;
}
$filters = array_unique(array_map('trim', $filters), SORT_STRING);
$filterActions = $this->filterActions();
//Check existing filters
for ($i = count($filterActions) - 1; $i >= 0; $i--) {
$filterAction = $filterActions[$i];
if ($filterAction == null || !is_array($filterAction->actions()) ||
$filterAction->booleanSearch() == null || trim($filterAction->booleanSearch()->getRawInput()) == '') {
array_splice($filterActions, $i, 1);
continue;
}
$actions = $filterAction->actions();
//Remove existing rules with same action
for ($j = count($actions) - 1; $j >= 0; $j--) {
if ($actions[$j] === $action) {
array_splice($actions, $j, 1);
}
}
//Update existing filter with new action
for ($k = count($filters) - 1; $k >= 0; $k--) {
$filter = $filters[$k];
if ($filter === $filterAction->booleanSearch()->getRawInput()) {
$actions[] = $action;
array_splice($filters, $k, 1);
}
}
//Save result
if (empty($actions)) {
array_splice($filterActions, $i, 1);
} else {
$filterAction->_actions($actions);
}
}
//Add new filters
for ($k = count($filters) - 1; $k >= 0; $k--) {
$filter = $filters[$k];
if ($filter != '') {
$filterAction = FreshRSS_FilterAction::fromJSON([
'search' => $filter,
'actions' => [$action],
]);
if ($filterAction != null) {
$filterActions[] = $filterAction;
}
}
}
if (empty($filterActions)) {
$filterActions = null;
}
$this->_filterActions($filterActions);
}
/**
* @param bool $applyLabel Parameter by reference, which will be set to true if the callers needs to apply a label to the article entry.
*/
public function applyFilterActions(FreshRSS_Entry $entry, ?bool &$applyLabel = null): void {
$applyLabel = false;
foreach ($this->filterActions() as $filterAction) {
if ($entry->matches($filterAction->booleanSearch())) {
foreach ($filterAction->actions() as $action) {
switch ($action) {
case 'read':
if (!$entry->isRead()) {
$entry->_isRead(true);
Minz_ExtensionManager::callHook('entry_auto_read', $entry, 'filter');
}
break;
case 'star':
if (!$entry->isUpdated()) {
// Do not apply to updated articles, to avoid overruling a user manual action
$entry->_isFavorite(true);
}
break;
case 'label':
if (!$entry->isUpdated()) {
// Do not apply to updated articles, to avoid overruling a user manual action
$applyLabel = true;
}
break;
}
}
}
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/FormAuth.php'
<?php
declare(strict_types=1);
class FreshRSS_FormAuth {
public static function checkCredentials(string $username, string $hash, string $nonce, string $challenge): bool {
if (!FreshRSS_user_Controller::checkUsername($username) ||
!ctype_graph($hash) ||
!ctype_graph($challenge) ||
!ctype_alnum($nonce)) {
Minz_Log::debug("Invalid credential parameters: user={$username}, challenge={$challenge}, nonce={$nonce}");
return false;
}
return password_verify($nonce . $hash, $challenge);
}
/** @return array<string> */
public static function getCredentialsFromCookie(): array {
$token = Minz_Session::getLongTermCookie('FreshRSS_login');
if (!ctype_alnum($token)) {
return [];
}
$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
$mtime = @filemtime($token_file) ?: 0;
$limits = FreshRSS_Context::systemConf()->limits;
$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
if ($mtime + $cookie_duration < time()) {
// Token has expired (> cookie_duration) or does not exist.
@unlink($token_file);
return [];
}
$credentials = @file_get_contents($token_file);
if ($credentials !== false && self::renewCookie($token)) {
return explode("\t", $credentials, 2);
}
return [];
}
/** @return string|false */
private static function renewCookie(string $token) {
$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
if (touch($token_file)) {
$limits = FreshRSS_Context::systemConf()->limits;
$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
$expire = time() + $cookie_duration;
Minz_Session::setLongTermCookie('FreshRSS_login', $token, $expire);
return $token;
}
return false;
}
/** @return string|false */
public static function makeCookie(string $username, string $password_hash) {
do {
$token = sha1(FreshRSS_Context::systemConf()->salt . $username . uniqid('' . mt_rand(), true));
$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
} while (file_exists($token_file));
if (@file_put_contents($token_file, $username . "\t" . $password_hash) === false) {
return false;
}
return self::renewCookie($token);
}
public static function deleteCookie(): void {
$token = Minz_Session::getLongTermCookie('FreshRSS_login');
if (ctype_alnum($token)) {
Minz_Session::deleteLongTermCookie('FreshRSS_login');
@unlink(DATA_PATH . '/tokens/' . $token . '.txt');
}
if (rand(0, 10) === 1) {
self::purgeTokens();
}
}
public static function purgeTokens(): void {
$limits = FreshRSS_Context::systemConf()->limits;
$cookie_duration = empty($limits['cookie_duration']) ? FreshRSS_Auth::DEFAULT_COOKIE_DURATION : $limits['cookie_duration'];
$oldest = time() - $cookie_duration;
foreach (new DirectoryIterator(DATA_PATH . '/tokens/') as $file_info) {
$extension = $file_info->getExtension();
if ($extension === 'txt' && $file_info->getMTime() < $oldest) {
@unlink($file_info->getPathname());
}
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Log.php'
<?php
declare(strict_types=1);
class FreshRSS_Log extends Minz_Model {
private string $date;
private string $level;
private string $information;
public function date(): string {
return $this->date;
}
public function level(): string {
return $this->level;
}
public function info(): string {
return $this->information;
}
public function _date(string $date): void {
$this->date = $date;
}
public function _level(string $level): void {
$this->level = $level;
}
public function _info(string $information): void {
$this->information = $information;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/LogDAO.php'
<?php
declare(strict_types=1);
final class FreshRSS_LogDAO {
public static function logPath(?string $logFileName = null): string {
if ($logFileName === null || $logFileName === '') {
$logFileName = LOG_FILENAME;
}
return USERS_PATH . '/' . (Minz_User::name() ?? Minz_User::INTERNAL_USER) . '/' . $logFileName;
}
/** @return array<FreshRSS_Log> */
public static function lines(?string $logFileName = null): array {
$logs = [];
$handle = @fopen(self::logPath($logFileName), 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
if (preg_match('/^\[([^\[]+)\] \[([^\[]+)\] --- (.*)$/', $line, $matches)) {
$myLog = new FreshRSS_Log();
$myLog->_date($matches[1]);
$myLog->_level($matches[2]);
$myLog->_info($matches[3]);
$logs[] = $myLog;
}
}
fclose($handle);
}
return array_reverse($logs);
}
public static function truncate(?string $logFileName = null): void {
file_put_contents(self::logPath($logFileName), '');
if (FreshRSS_Auth::hasAccess('admin')) {
file_put_contents(ADMIN_LOG, '');
file_put_contents(API_LOG, '');
file_put_contents(PSHB_LOG, '');
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/ReadingMode.php'
<?php
declare(strict_types=1);
/**
* Manage the reading modes in FreshRSS.
*/
class FreshRSS_ReadingMode {
protected string $id;
protected string $name;
protected string $title;
/** @var array{c:string,a:string,params:array<string,mixed>} */
protected array $urlParams;
protected bool $isActive = false;
/**
* ReadingMode constructor.
* @param array{c:string,a:string,params:array<string,mixed>} $urlParams
*/
public function __construct(string $id, string $title, array $urlParams, bool $active) {
$this->id = $id;
$this->name = _i($id);
$this->title = $title;
$this->urlParams = $urlParams;
$this->isActive = $active;
}
public function getId(): string {
return $this->id;
}
public function getName(): string {
return $this->name;
}
public function setName(string $name): FreshRSS_ReadingMode {
$this->name = $name;
return $this;
}
public function getTitle(): string {
return $this->title;
}
public function setTitle(string $title): FreshRSS_ReadingMode {
$this->title = $title;
return $this;
}
/** @return array{c:string,a:string,params:array<string,mixed>} */
public function getUrlParams(): array {
return $this->urlParams;
}
/** @param array{c:string,a:string,params:array<string,mixed>} $urlParams */
public function setUrlParams(array $urlParams): FreshRSS_ReadingMode {
$this->urlParams = $urlParams;
return $this;
}
public function isActive(): bool {
return $this->isActive;
}
public function setIsActive(bool $isActive): FreshRSS_ReadingMode {
$this->isActive = $isActive;
return $this;
}
/**
* @return array<FreshRSS_ReadingMode> the built-in reading modes
*/
public static function getReadingModes(): array {
$actualView = Minz_Request::actionName();
$defaultCtrl = Minz_Request::defaultControllerName();
$isDefaultCtrl = Minz_Request::controllerName() === $defaultCtrl;
$urlOutput = Minz_Request::currentRequest();
$readingModes = [
new FreshRSS_ReadingMode(
"view-normal",
_t('index.menu.normal_view'),
array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'normal']),
($isDefaultCtrl && $actualView === 'normal')
),
new FreshRSS_ReadingMode(
"view-global",
_t('index.menu.global_view'),
array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'global']),
($isDefaultCtrl && $actualView === 'global')
),
new FreshRSS_ReadingMode(
"view-reader",
_t('index.menu.reader_view'),
array_merge($urlOutput, ['c' => $defaultCtrl, 'a' => 'reader']),
($isDefaultCtrl && $actualView === 'reader')
)
];
return $readingModes;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Search.php'
<?php
declare(strict_types=1);
require_once(LIB_PATH . '/lib_date.php');
/**
* Contains a search from the search form.
*
* It allows to extract meaningful bits of the search and store them in a
* convenient object
*/
class FreshRSS_Search {
/**
* This contains the user input string
*/
private string $raw_input = '';
// The following properties are extracted from the raw input
/** @var array<string>|null */
private ?array $entry_ids = null;
/** @var array<int>|null */
private ?array $feed_ids = null;
/** @var array<int>|'*'|null */
private $label_ids = null;
/** @var array<string>|null */
private ?array $label_names = null;
/** @var array<string>|null */
private ?array $intitle = null;
/** @var int|false|null */
private $min_date = null;
/** @var int|false|null */
private $max_date = null;
/** @var int|false|null */
private $min_pubdate = null;
/** @var int|false|null */
private $max_pubdate = null;
/** @var array<string>|null */
private ?array $inurl = null;
/** @var array<string>|null */
private ?array $author = null;
/** @var array<string>|null */
private ?array $tags = null;
/** @var array<string>|null */
private ?array $search = null;
/** @var array<string>|null */
private ?array $not_entry_ids = null;
/** @var array<int>|null */
private ?array $not_feed_ids = null;
/** @var array<int>|'*'|null */
private $not_label_ids = null;
/** @var array<string>|null */
private ?array $not_label_names = null;
/** @var array<string>|null */
private ?array $not_intitle = null;
/** @var int|false|null */
private $not_min_date = null;
/** @var int|false|null */
private $not_max_date = null;
/** @var int|false|null */
private $not_min_pubdate = null;
/** @var int|false|null */
private $not_max_pubdate = null;
/** @var array<string>|null */
private ?array $not_inurl = null;
/** @var array<string>|null */
private ?array $not_author = null;
/** @var array<string>|null */
private ?array $not_tags = null;
/** @var array<string>|null */
private ?array $not_search = null;
public function __construct(string $input) {
$input = self::cleanSearch($input);
$input = self::unescape($input);
$this->raw_input = $input;
$input = $this->parseNotEntryIds($input);
$input = $this->parseNotFeedIds($input);
$input = $this->parseNotLabelIds($input);
$input = $this->parseNotLabelNames($input);
$input = $this->parseNotPubdateSearch($input);
$input = $this->parseNotDateSearch($input);
$input = $this->parseNotIntitleSearch($input);
$input = $this->parseNotAuthorSearch($input);
$input = $this->parseNotInurlSearch($input);
$input = $this->parseNotTagsSearch($input);
$input = $this->parseEntryIds($input);
$input = $this->parseFeedIds($input);
$input = $this->parseLabelIds($input);
$input = $this->parseLabelNames($input);
$input = $this->parsePubdateSearch($input);
$input = $this->parseDateSearch($input);
$input = $this->parseIntitleSearch($input);
$input = $this->parseAuthorSearch($input);
$input = $this->parseInurlSearch($input);
$input = $this->parseTagsSearch($input);
$input = $this->parseQuotedSearch($input);
$input = $this->parseNotSearch($input);
$this->parseSearch($input);
}
#[\Override]
public function __toString(): string {
return $this->getRawInput();
}
public function getRawInput(): string {
return $this->raw_input;
}
/** @return array<string>|null */
public function getEntryIds(): ?array {
return $this->entry_ids;
}
/** @return array<string>|null */
public function getNotEntryIds(): ?array {
return $this->not_entry_ids;
}
/** @return array<int>|null */
public function getFeedIds(): ?array {
return $this->feed_ids;
}
/** @return array<int>|null */
public function getNotFeedIds(): ?array {
return $this->not_feed_ids;
}
/** @return array<int>|'*'|null */
public function getLabelIds() {
return $this->label_ids;
}
/** @return array<int>|'*'|null */
public function getNotLabelIds() {
return $this->not_label_ids;
}
/** @return array<string>|null */
public function getLabelNames(): ?array {
return $this->label_names;
}
/** @return array<string>|null */
public function getNotLabelNames(): ?array {
return $this->not_label_names;
}
/** @return array<string>|null */
public function getIntitle(): ?array {
return $this->intitle;
}
/** @return array<string>|null */
public function getNotIntitle(): ?array {
return $this->not_intitle;
}
public function getMinDate(): ?int {
return $this->min_date ?: null;
}
public function getNotMinDate(): ?int {
return $this->not_min_date ?: null;
}
public function setMinDate(int $value): void {
$this->min_date = $value;
}
public function getMaxDate(): ?int {
return $this->max_date ?: null;
}
public function getNotMaxDate(): ?int {
return $this->not_max_date ?: null;
}
public function setMaxDate(int $value): void {
$this->max_date = $value;
}
public function getMinPubdate(): ?int {
return $this->min_pubdate ?: null;
}
public function getNotMinPubdate(): ?int {
return $this->not_min_pubdate ?: null;
}
public function getMaxPubdate(): ?int {
return $this->max_pubdate ?: null;
}
public function getNotMaxPubdate(): ?int {
return $this->not_max_pubdate ?: null;
}
/** @return array<string>|null */
public function getInurl(): ?array {
return $this->inurl;
}
/** @return array<string>|null */
public function getNotInurl(): ?array {
return $this->not_inurl;
}
/** @return array<string>|null */
public function getAuthor(): ?array {
return $this->author;
}
/** @return array<string>|null */
public function getNotAuthor(): ?array {
return $this->not_author;
}
/** @return array<string>|null */
public function getTags(): ?array {
return $this->tags;
}
/** @return array<string>|null */
public function getNotTags(): ?array {
return $this->not_tags;
}
/** @return array<string>|null */
public function getSearch(): ?array {
return $this->search;
}
/** @return array<string>|null */
public function getNotSearch(): ?array {
return $this->not_search;
}
/**
* @param array<string>|null $anArray
* @return array<string>
*/
private static function removeEmptyValues(?array $anArray): array {
return empty($anArray) ? [] : array_filter($anArray, static fn(string $value) => $value !== '');
}
/**
* @param array<string>|string $value
* @return ($value is array ? array<string> : string)
*/
private static function decodeSpaces($value) {
if (is_array($value)) {
for ($i = count($value) - 1; $i >= 0; $i--) {
$value[$i] = self::decodeSpaces($value[$i]);
}
} else {
$value = trim(str_replace('+', ' ', $value));
}
return $value;
}
/**
* Parse the search string to find entry (article) IDs.
*/
private function parseEntryIds(string $input): string {
if (preg_match_all('/\be:(?P<search>[0-9,]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->entry_ids = [];
foreach ($ids_lists as $ids_list) {
$entry_ids = explode(',', $ids_list);
$entry_ids = self::removeEmptyValues($entry_ids);
if (!empty($entry_ids)) {
$this->entry_ids = array_merge($this->entry_ids, $entry_ids);
}
}
}
return $input;
}
private function parseNotEntryIds(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->not_entry_ids = [];
foreach ($ids_lists as $ids_list) {
$entry_ids = explode(',', $ids_list);
$entry_ids = self::removeEmptyValues($entry_ids);
if (!empty($entry_ids)) {
$this->not_entry_ids = array_merge($this->not_entry_ids, $entry_ids);
}
}
}
return $input;
}
private function parseFeedIds(string $input): string {
if (preg_match_all('/\bf:(?P<search>[0-9,]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->feed_ids = [];
foreach ($ids_lists as $ids_list) {
$feed_ids = explode(',', $ids_list);
$feed_ids = self::removeEmptyValues($feed_ids);
/** @var array<int> $feed_ids */
$feed_ids = array_map('intval', $feed_ids);
if (!empty($feed_ids)) {
$this->feed_ids = array_merge($this->feed_ids, $feed_ids);
}
}
}
return $input;
}
private function parseNotFeedIds(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->not_feed_ids = [];
foreach ($ids_lists as $ids_list) {
$feed_ids = explode(',', $ids_list);
$feed_ids = self::removeEmptyValues($feed_ids);
/** @var array<int> $feed_ids */
$feed_ids = array_map('intval', $feed_ids);
if (!empty($feed_ids)) {
$this->not_feed_ids = array_merge($this->not_feed_ids, $feed_ids);
}
}
}
return $input;
}
/**
* Parse the search string to find tags (labels) IDs.
*/
private function parseLabelIds(string $input): string {
if (preg_match_all('/\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->label_ids = [];
foreach ($ids_lists as $ids_list) {
if ($ids_list === '*') {
$this->label_ids = '*';
break;
}
$label_ids = explode(',', $ids_list);
$label_ids = self::removeEmptyValues($label_ids);
/** @var array<int> $label_ids */
$label_ids = array_map('intval', $label_ids);
if (!empty($label_ids)) {
$this->label_ids = array_merge($this->label_ids, $label_ids);
}
}
}
return $input;
}
private function parseNotLabelIds(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$ids_lists = $matches['search'];
$this->not_label_ids = [];
foreach ($ids_lists as $ids_list) {
if ($ids_list === '*') {
$this->not_label_ids = '*';
break;
}
$label_ids = explode(',', $ids_list);
$label_ids = self::removeEmptyValues($label_ids);
/** @var array<int> $label_ids */
$label_ids = array_map('intval', $label_ids);
if (!empty($label_ids)) {
$this->not_label_ids = array_merge($this->not_label_ids, $label_ids);
}
}
}
return $input;
}
/**
* Parse the search string to find tags (labels) names.
*/
private function parseLabelNames(string $input): string {
$names_lists = [];
if (preg_match_all('/\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$names_lists = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) {
$names_lists = array_merge($names_lists, $matches['search']);
$input = str_replace($matches[0], '', $input);
}
if (!empty($names_lists)) {
$this->label_names = [];
foreach ($names_lists as $names_list) {
$names_array = explode(',', $names_list);
$names_array = self::removeEmptyValues($names_array);
if (!empty($names_array)) {
$this->label_names = array_merge($this->label_names, $names_array);
}
}
}
return $input;
}
/**
* Parse the search string to find tags (labels) names to exclude.
*/
private function parseNotLabelNames(string $input): string {
$names_lists = [];
if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$names_lists = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<search>[^\s"]*)/', $input, $matches)) {
$names_lists = array_merge($names_lists, $matches['search']);
$input = str_replace($matches[0], '', $input);
}
if (!empty($names_lists)) {
$this->not_label_names = [];
foreach ($names_lists as $names_list) {
$names_array = explode(',', $names_list);
$names_array = self::removeEmptyValues($names_array);
if (!empty($names_array)) {
$this->not_label_names = array_merge($this->not_label_names, $names_array);
}
}
}
return $input;
}
/**
* Parse the search string to find intitle keyword and the search related to it.
* The search is the first word following the keyword.
*/
private function parseIntitleSearch(string $input): string {
if (preg_match_all('/\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->intitle = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
$this->intitle = array_merge($this->intitle ?: [], $matches['search']);
$input = str_replace($matches[0], '', $input);
}
$this->intitle = self::removeEmptyValues($this->intitle);
if (empty($this->intitle)) {
$this->intitle = null;
}
return $input;
}
private function parseNotIntitleSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->not_intitle = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
$this->not_intitle = array_merge($this->not_intitle ?: [], $matches['search']);
$input = str_replace($matches[0], '', $input);
}
$this->not_intitle = self::removeEmptyValues($this->not_intitle);
if (empty($this->not_intitle)) {
$this->not_intitle = null;
}
return $input;
}
/**
* Parse the search string to find author keyword and the search related to it.
* The search is the first word following the keyword except when using
* a delimiter. Supported delimiters are single quote (') and double quotes (").
*/
private function parseAuthorSearch(string $input): string {
if (preg_match_all('/\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->author = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
$this->author = array_merge($this->author ?: [], $matches['search']);
$input = str_replace($matches[0], '', $input);
}
$this->author = self::removeEmptyValues($this->author);
if (empty($this->author)) {
$this->author = null;
}
return $input;
}
private function parseNotAuthorSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->not_author = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
if (preg_match_all('/(?<=\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
$this->not_author = array_merge($this->not_author ?: [], $matches['search']);
$input = str_replace($matches[0], '', $input);
}
$this->not_author = self::removeEmptyValues($this->not_author);
if (empty($this->not_author)) {
$this->not_author = null;
}
return $input;
}
/**
* Parse the search string to find inurl keyword and the search related to it.
* The search is the first word following the keyword.
*/
private function parseInurlSearch(string $input): string {
if (preg_match_all('/\binurl:(?P<search>[^\s]*)/', $input, $matches)) {
$this->inurl = $matches['search'];
$input = str_replace($matches[0], '', $input);
$this->inurl = self::removeEmptyValues($this->inurl);
}
return $input;
}
private function parseNotInurlSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]inurl:(?P<search>[^\s]*)/', $input, $matches)) {
$this->not_inurl = $matches['search'];
$input = str_replace($matches[0], '', $input);
$this->not_inurl = self::removeEmptyValues($this->not_inurl);
}
return $input;
}
/**
* Parse the search string to find date keyword and the search related to it.
* The search is the first word following the keyword.
*/
private function parseDateSearch(string $input): string {
if (preg_match_all('/\bdate:(?P<search>[^\s]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$dates = self::removeEmptyValues($matches['search']);
if (!empty($dates[0])) {
[$this->min_date, $this->max_date] = parseDateInterval($dates[0]);
}
}
return $input;
}
private function parseNotDateSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]date:(?P<search>[^\s]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$dates = self::removeEmptyValues($matches['search']);
if (!empty($dates[0])) {
[$this->not_min_date, $this->not_max_date] = parseDateInterval($dates[0]);
}
}
return $input;
}
/**
* Parse the search string to find pubdate keyword and the search related to it.
* The search is the first word following the keyword.
*/
private function parsePubdateSearch(string $input): string {
if (preg_match_all('/\bpubdate:(?P<search>[^\s]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$dates = self::removeEmptyValues($matches['search']);
if (!empty($dates[0])) {
[$this->min_pubdate, $this->max_pubdate] = parseDateInterval($dates[0]);
}
}
return $input;
}
private function parseNotPubdateSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]pubdate:(?P<search>[^\s]*)/', $input, $matches)) {
$input = str_replace($matches[0], '', $input);
$dates = self::removeEmptyValues($matches['search']);
if (!empty($dates[0])) {
[$this->not_min_pubdate, $this->not_max_pubdate] = parseDateInterval($dates[0]);
}
}
return $input;
}
/**
* Parse the search string to find tags keyword (# followed by a word)
* and the search related to it.
* The search is the first word following the #.
*/
private function parseTagsSearch(string $input): string {
if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) {
$this->tags = $matches['search'];
$input = str_replace($matches[0], '', $input);
$this->tags = self::removeEmptyValues($this->tags);
$this->tags = self::decodeSpaces($this->tags);
}
return $input;
}
private function parseNotTagsSearch(string $input): string {
if (preg_match_all('/(?<=\s|^)[!-]#(?P<search>[^\s]+)/', $input, $matches)) {
$this->not_tags = $matches['search'];
$input = str_replace($matches[0], '', $input);
$this->not_tags = self::removeEmptyValues($this->not_tags);
$this->not_tags = self::decodeSpaces($this->not_tags);
}
return $input;
}
/**
* Parse the search string to find search values.
* Every word is a distinct search value using a delimiter.
* Supported delimiters are single quote (') and double quotes (").
*/
private function parseQuotedSearch(string $input): string {
$input = self::cleanSearch($input);
if ($input === '') {
return '';
}
if (preg_match_all('/(?<![!-])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->search = $matches['search'];
//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
$input = str_replace($matches[0], '', $input);
}
return $input;
}
/**
* Parse the search string to find search values.
* Every word is a distinct search value.
*/
private function parseSearch(string $input): string {
$input = self::cleanSearch($input);
if ($input === '') {
return '';
}
if (is_array($this->search)) {
$this->search = array_merge($this->search, explode(' ', $input));
} else {
$this->search = explode(' ', $input);
}
return $input;
}
private function parseNotSearch(string $input): string {
$input = self::cleanSearch($input);
if ($input === '') {
return '';
}
if (preg_match_all('/(?<=\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
$this->not_search = $matches['search'];
$input = str_replace($matches[0], '', $input);
}
$input = self::cleanSearch($input);
if ($input === '') {
return '';
}
if (preg_match_all('/(?<=\s|^)[!-](?P<search>[^\s]+)/', $input, $matches)) {
$this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']);
$input = str_replace($matches[0], '', $input);
}
$this->not_search = self::removeEmptyValues($this->not_search);
return $input;
}
/**
* Remove all unnecessary spaces in the search
*/
private static function cleanSearch(string $input): string {
$input = preg_replace('/\s+/', ' ', $input);
if (!is_string($input)) {
return '';
}
return trim($input);
}
/** Remove escaping backslashes for parenthesis logic */
private static function unescape(string $input): string {
return str_replace(['\\(', '\\)'], ['(', ')'], $input);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Share.php'
<?php
declare(strict_types=1);
/**
* Manage the sharing options in FreshRSS.
*/
class FreshRSS_Share {
/**
* The list of available sharing options.
* @var array<string,FreshRSS_Share>
*/
private static array $list_sharing = [];
/**
* Register a new sharing option.
* @param array{'type':string,'url':string,'transform'?:array<callable>|array<string,array<callable>>,'field'?:string,'help'?:string,'form'?:'simple'|'advanced',
* 'method'?:'GET'|'POST','HTMLtag'?:'button','deprecated'?:bool} $share_options is an array defining the share option.
*/
public static function register(array $share_options): void {
$type = $share_options['type'];
if (isset(self::$list_sharing[$type])) {
return;
}
self::$list_sharing[$type] = new FreshRSS_Share(
$type,
$share_options['url'],
$share_options['transform'] ?? [],
$share_options['form'] ?? 'simple',
$share_options['help'] ?? '',
$share_options['method'] ?? 'GET',
$share_options['field'] ?? null,
$share_options['HTMLtag'] ?? null,
$share_options['deprecated'] ?? false
);
}
/**
* Register sharing options in a file.
* @param string $filename the name of the file to load.
*/
public static function load(string $filename): void {
$shares_from_file = @include($filename);
if (!is_array($shares_from_file)) {
$shares_from_file = [];
}
foreach ($shares_from_file as $share_type => $share_options) {
$share_options['type'] = $share_type;
self::register($share_options);
}
uasort(self::$list_sharing, static fn(FreshRSS_Share $a, FreshRSS_Share $b) => strcasecmp($a->name() ?? '', $b->name() ?? ''));
}
/**
* Return the list of sharing options.
* @return array<string,FreshRSS_Share>
*/
public static function enum(): array {
return self::$list_sharing;
}
/**
* @param string $type the share type, null if $type is not registered.
* @return FreshRSS_Share|null object related to the given type.
*/
public static function get(string $type): ?FreshRSS_Share {
return self::$list_sharing[$type] ?? null;
}
private string $type;
private string $name;
private string $url_transform;
/** @var array<callable>|array<string,array<callable>> */
private array $transforms;
/**
* @phpstan-var 'simple'|'advanced'
*/
private string $form_type;
private string $help_url;
private ?string $custom_name = null;
private ?string $base_url = null;
private ?string $id = null;
private ?string $title = null;
private ?string $link = null;
private bool $isDeprecated;
/**
* @phpstan-var 'GET'|'POST'
*/
private string $method;
private ?string $field;
/**
* @phpstan-var 'button'|null
*/
private ?string $HTMLtag;
/**
* Create a FreshRSS_Share object.
* @param string $type is a unique string defining the kind of share option.
* @param string $url_transform defines the url format to use in order to share.
* @param array<callable>|array<string,array<callable>> $transforms is an array of transformations to apply on link and title.
* @param 'simple'|'advanced' $form_type defines which form we have to use to complete. "simple"
* is typically for a centralized service while "advanced" is for
* decentralized ones.
* @param string $help_url is an optional url to give help on this option.
* @param 'GET'|'POST' $method defines the sharing method (GET or POST)
* @param string|null $field
* @param 'button'|null $HTMLtag
* @param bool $isDeprecated
*/
private function __construct(string $type, string $url_transform, array $transforms, string $form_type,
string $help_url, string $method, ?string $field, ?string $HTMLtag, bool $isDeprecated = false) {
$this->type = $type;
$this->name = _t('gen.share.' . $type);
$this->url_transform = $url_transform;
$this->help_url = $help_url;
$this->HTMLtag = $HTMLtag;
$this->isDeprecated = $isDeprecated;
$this->transforms = $transforms;
if (!in_array($form_type, ['simple', 'advanced'], true)) {
$form_type = 'simple';
}
$this->form_type = $form_type;
if (!in_array($method, ['GET', 'POST'], true)) {
$method = 'GET';
}
$this->method = $method;
$this->field = $field;
}
/**
* Update a FreshRSS_Share object with information from an array.
* @param array<string,string> $options is a list of information to update where keys should be
* in this list: name, url, id, title, link.
*/
public function update(array $options): void {
foreach ($options as $key => $value) {
switch ($key) {
case 'name':
$this->custom_name = $value;
break;
case 'url':
$this->base_url = $value;
break;
case 'id':
$this->id = $value;
break;
case 'title':
$this->title = $value;
break;
case 'link':
$this->link = $value;
break;
case 'method':
$this->method = strcasecmp($value, 'POST') === 0 ? 'POST' : 'GET';
break;
case 'field':
$this->field = $value;
break;
}
}
}
/**
* Return the current type of the share option.
*/
public function type(): string {
return $this->type;
}
/**
* Return the current method of the share option.
* @return 'GET'|'POST'
*/
public function method(): string {
return $this->method;
}
/**
* Return the current field of the share option. Itβs null for shares
* using the GET method.
*/
public function field(): ?string {
return $this->field;
}
/**
* Return the current form type of the share option.
* @return 'simple'|'advanced'
*/
public function formType(): string {
return $this->form_type;
}
/**
* Return the current help url of the share option.
*/
public function help(): string {
return $this->help_url;
}
/**
* Return the custom type of HTML tag of the share option, null for default.
* @return 'button'|null
*/
public function HTMLtag(): ?string {
return $this->HTMLtag;
}
/**
* Return the current name of the share option.
*/
public function name(bool $real = false): ?string {
if ($real || empty($this->custom_name)) {
return $this->name;
} else {
return $this->custom_name;
}
}
/**
* Return the current base url of the share option.
*/
public function baseUrl(): string {
return $this->base_url ?? '';
}
/**
* Return the deprecated status of the share option.
*/
public function isDeprecated(): bool {
return $this->isDeprecated;
}
/**
* Return the current url by merging url_transform and base_url.
*/
public function url(): string {
$matches = [
'~ID~',
'~URL~',
'~TITLE~',
'~LINK~',
];
$replaces = [
$this->id(),
$this->base_url,
$this->title(),
$this->link(),
];
return str_replace($matches, $replaces, $this->url_transform);
}
/**
* Return the id.
* @param bool $raw true if we should get the id without transformations.
*/
public function id(bool $raw = false): ?string {
if ($raw) {
return $this->id;
}
if ($this->id === null) {
return null;
}
return self::transform($this->id, $this->getTransform('id'));
}
/**
* Return the title.
* @param bool $raw true if we should get the title without transformations.
*/
public function title(bool $raw = false): string {
if ($raw) {
return $this->title ?? '';
}
if ($this->title === null) {
return '';
}
return self::transform($this->title, $this->getTransform('title'));
}
/**
* Return the link.
* @param bool $raw true if we should get the link without transformations.
*/
public function link(bool $raw = false): string {
if ($raw) {
return $this->link ?? '';
}
if ($this->link === null) {
return '';
}
return self::transform($this->link, $this->getTransform('link'));
}
/**
* Transform a data with the given functions.
* @param string $data the data to transform.
* @param array<callable> $transform an array containing a list of functions to apply.
* @return string the transformed data.
*/
private static function transform(string $data, array $transform): string {
if (empty($transform)) {
return $data;
}
foreach ($transform as $action) {
$data = call_user_func($action, $data);
}
return $data;
}
/**
* Get the list of transformations for the given attribute.
* @param string $attr the attribute of which we want the transformations.
* @return array<callable> containing a list of transformations to apply.
*/
private function getTransform(string $attr): array {
if (array_key_exists($attr, $this->transforms)) {
$candidates = is_array($this->transforms[$attr]) ? $this->transforms[$attr] : [];
} else {
$candidates = $this->transforms;
}
$transforms = [];
foreach ($candidates as $transform) {
if (is_callable($transform)) {
$transforms[] = $transform;
}
}
return $transforms;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/StatsDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_StatsDAO extends Minz_ModelPdo {
public const ENTRY_COUNT_PERIOD = 30;
protected function sqlFloor(string $s): string {
return "FLOOR($s)";
}
/**
* Calculates entry repartition for all feeds and for main stream.
*
* @return array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false}
*/
public function calculateEntryRepartition(): array {
return [
'main_stream' => $this->calculateEntryRepartitionPerFeed(null, true),
'all_feeds' => $this->calculateEntryRepartitionPerFeed(null, false),
];
}
/**
* Calculates entry repartition for the selection.
* The repartition includes:
* - total entries
* - read entries
* - unread entries
* - favorite entries
*
* @return array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false
*/
public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false) {
$filter = '';
if ($only_main) {
$filter .= 'AND f.priority = 10';
}
if (!is_null($feed)) {
$filter .= "AND e.id_feed = {$feed}";
}
$sql = <<<SQL
SELECT COUNT(1) AS total,
COUNT(1) - SUM(e.is_read) AS count_unreads,
SUM(e.is_read) AS count_reads,
SUM(e.is_favorite) AS count_favorites
FROM `_entry` AS e, `_feed` AS f
WHERE e.id_feed = f.id
{$filter}
SQL;
$res = $this->fetchAssoc($sql);
if (!empty($res[0])) {
$dao = $res[0];
/** @var array<array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}> $res */
FreshRSS_DatabaseDAO::pdoInt($dao, ['total', 'count_unreads', 'count_reads', 'count_favorites']);
return $dao;
}
return false;
}
/**
* Calculates entry count per day on a 30 days period.
* @return array<int,int>
*/
public function calculateEntryCount(): array {
$count = $this->initEntryCountArray();
$midnight = mktime(0, 0, 0) ?: 0;
$oldest = $midnight - (self::ENTRY_COUNT_PERIOD * 86400);
// Get stats per day for the last 30 days
$sqlDay = $this->sqlFloor("(date - $midnight) / 86400");
$sql = <<<SQL
SELECT {$sqlDay} AS day,
COUNT(*) as count
FROM `_entry`
WHERE date >= {$oldest} AND date < {$midnight}
GROUP BY day
ORDER BY day ASC
SQL;
$res = $this->fetchAssoc($sql);
if ($res == false) {
return [];
}
/** @var array<array{'day':int,'count':int}> $res */
foreach ($res as $value) {
$count[(int)($value['day'])] = (int)($value['count']);
}
return $count;
}
/**
* Initialize an array for the entry count.
* @return array<int,int>
*/
protected function initEntryCountArray(): array {
return $this->initStatsArray(-self::ENTRY_COUNT_PERIOD, -1);
}
/**
* Calculates the number of article per hour of the day per feed
* @return array<int,int>
*/
public function calculateEntryRepartitionPerFeedPerHour(?int $feed = null): array {
return $this->calculateEntryRepartitionPerFeedPerPeriod('%H', $feed);
}
/**
* Calculates the number of article per day of week per feed
* @return array<int,int>
*/
public function calculateEntryRepartitionPerFeedPerDayOfWeek(?int $feed = null): array {
return $this->calculateEntryRepartitionPerFeedPerPeriod('%w', $feed);
}
/**
* Calculates the number of article per month per feed
* @return array<int,int>
*/
public function calculateEntryRepartitionPerFeedPerMonth(?int $feed = null): array {
$monthRepartition = $this->calculateEntryRepartitionPerFeedPerPeriod('%m', $feed);
// cut out the 0th month (Jan=1, Dec=12)
\array_splice($monthRepartition, 0, 1);
return $monthRepartition;
}
/**
* Calculates the number of article per period per feed
* @param string $period format string to use for grouping
* @return array<int,int>
*/
protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
$restrict = '';
if ($feed) {
$restrict = "WHERE e.id_feed = {$feed}";
}
$sql = <<<SQL
SELECT DATE_FORMAT(FROM_UNIXTIME(e.date), '{$period}') AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;
$res = $this->fetchAssoc($sql);
if ($res == false) {
return [];
}
switch ($period) {
case '%H':
$periodMax = 24;
break;
case '%w':
$periodMax = 7;
break;
case '%m':
$periodMax = 12;
break;
default:
$periodMax = 30;
}
$repartition = array_fill(0, $periodMax, 0);
foreach ($res as $value) {
$repartition[(int)$value['period']] = (int)$value['count'];
}
return $repartition;
}
/**
* Calculates the average number of article per hour per feed
*/
public function calculateEntryAveragePerFeedPerHour(?int $feed = null): float {
return $this->calculateEntryAveragePerFeedPerPeriod(1 / 24, $feed);
}
/**
* Calculates the average number of article per day of week per feed
*/
public function calculateEntryAveragePerFeedPerDayOfWeek(?int $feed = null): float {
return $this->calculateEntryAveragePerFeedPerPeriod(7, $feed);
}
/**
* Calculates the average number of article per month per feed
*/
public function calculateEntryAveragePerFeedPerMonth(?int $feed = null): float {
return $this->calculateEntryAveragePerFeedPerPeriod(30, $feed);
}
/**
* Calculates the average number of article per feed
* @param float $period number used to divide the number of day in the period
*/
protected function calculateEntryAveragePerFeedPerPeriod(float $period, ?int $feed = null): float {
$restrict = '';
if ($feed) {
$restrict = "WHERE e.id_feed = {$feed}";
}
$sql = <<<SQL
SELECT COUNT(1) AS count
, MIN(date) AS date_min
, MAX(date) AS date_max
FROM `_entry` AS e
{$restrict}
SQL;
$res = $this->fetchAssoc($sql);
if ($res == null || empty($res[0])) {
return -1.0;
}
$date_min = new \DateTime();
$date_min->setTimestamp((int)($res[0]['date_min']));
$date_max = new \DateTime();
$date_max->setTimestamp((int)($res[0]['date_max']));
$interval = $date_max->diff($date_min, true);
$interval_in_days = (float)($interval->format('%a'));
if ($interval_in_days <= 0) {
// Surely only one article.
// We will return count / (period/period) == count.
$interval_in_days = $period;
}
return (int)$res[0]['count'] / ($interval_in_days / $period);
}
/**
* Initialize an array for statistics depending on a range
* @return array<int,int>
*/
protected function initStatsArray(int $min, int $max): array {
return array_map(fn() => 0, array_flip(range($min, $max)));
}
/**
* Calculates feed count per category.
* @return array<array{'label':string,'data':int}>
*/
public function calculateFeedByCategory(): array {
$sql = <<<SQL
SELECT c.name AS label
, COUNT(f.id) AS data
FROM `_category` AS c, `_feed` AS f
WHERE c.id = f.category
GROUP BY label
ORDER BY data DESC
SQL;
/** @var array<array{'label':string,'data':int}>|null @res */
$res = $this->fetchAssoc($sql);
return $res == null ? [] : $res;
}
/**
* Calculates entry count per category.
* @return array<array{'label':string,'data':int}>
*/
public function calculateEntryByCategory(): array {
$sql = <<<SQL
SELECT c.name AS label
, COUNT(e.id) AS data
FROM `_category` AS c, `_feed` AS f, `_entry` AS e
WHERE c.id = f.category
AND f.id = e.id_feed
GROUP BY label
ORDER BY data DESC
SQL;
$res = $this->fetchAssoc($sql);
/** @var array<array{'label':string,'data':int}>|null $res */
return $res == null ? [] : $res;
}
/**
* Calculates the 10 top feeds based on their number of entries
* @return array<array{'id':int,'name':string,'category':string,'count':int}>
*/
public function calculateTopFeed(): array {
$sql = <<<SQL
SELECT f.id AS id
, MAX(f.name) AS name
, MAX(c.name) AS category
, COUNT(e.id) AS count
FROM `_category` AS c, `_feed` AS f, `_entry` AS e
WHERE c.id = f.category
AND f.id = e.id_feed
GROUP BY f.id
ORDER BY count DESC
LIMIT 10
SQL;
$res = $this->fetchAssoc($sql);
/** @var array<array{'id':int,'name':string,'category':string,'count':int}>|null $res */
if (is_array($res)) {
foreach ($res as &$dao) {
FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'count']);
}
return $res;
}
return [];
}
/**
* Calculates the last publication date for each feed
* @return array<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>
*/
public function calculateFeedLastDate(): array {
$sql = <<<SQL
SELECT MAX(f.id) as id
, MAX(f.name) AS name
, MAX(date) AS last_date
, COUNT(*) AS nb_articles
FROM `_feed` AS f, `_entry` AS e
WHERE f.id = e.id_feed
GROUP BY f.id
ORDER BY name
SQL;
$res = $this->fetchAssoc($sql);
/** @var array<array{'id':int,'name':string,'last_date':int,'nb_articles':int}>|null $res */
if (is_array($res)) {
foreach ($res as &$dao) {
FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'last_date', 'nb_articles']);
}
return $res;
}
return [];
}
/**
* Gets days ready for graphs
* @return array<string>
*/
public function getDays(): array {
return $this->convertToTranslatedJson([
'sun',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
]);
}
/**
* Gets months ready for graphs
* @return array<string>
*/
public function getMonths(): array {
return $this->convertToTranslatedJson([
'jan',
'feb',
'mar',
'apr',
'may_',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
]);
}
/**
* Translates array content
* @param array<string> $data
* @return array<string>
*/
private function convertToTranslatedJson(array $data = []): array {
$translated = array_map(static fn(string $a) => _t('gen.date.' . $a), $data);
return $translated;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/StatsDAOPGSQL.php'
<?php
declare(strict_types=1);
class FreshRSS_StatsDAOPGSQL extends FreshRSS_StatsDAO {
/**
* Calculates the number of article per hour of the day per feed
*
* @param int $feed id
* @return array<int,int>
*/
#[\Override]
public function calculateEntryRepartitionPerFeedPerHour(?int $feed = null): array {
return $this->calculateEntryRepartitionPerFeedPerPeriod('hour', $feed);
}
/**
* Calculates the number of article per day of week per feed
* @return array<int,int>
*/
#[\Override]
public function calculateEntryRepartitionPerFeedPerDayOfWeek(?int $feed = null): array {
return $this->calculateEntryRepartitionPerFeedPerPeriod('day', $feed);
}
/**
* Calculates the number of article per month per feed
* @return array<int,int>
*/
#[\Override]
public function calculateEntryRepartitionPerFeedPerMonth(?int $feed = null): array {
return $this->calculateEntryRepartitionPerFeedPerPeriod('month', $feed);
}
/**
* Calculates the number of article per period per feed
* @param string $period format string to use for grouping
* @return array<int,int>
*/
#[\Override]
protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
$restrict = '';
if ($feed) {
$restrict = "WHERE e.id_feed = {$feed}";
}
$sql = <<<SQL
SELECT extract( {$period} from to_timestamp(e.date)) AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;
$res = $this->fetchAssoc($sql);
if ($res == null) {
return [];
}
switch ($period) {
case 'hour':
$periodMax = 24;
break;
case 'day':
$periodMax = 7;
break;
case 'month':
$periodMax = 12;
break;
default:
$periodMax = 30;
}
$repartition = array_fill(0, $periodMax, 0);
foreach ($res as $value) {
$repartition[(int)$value['period']] = (int)$value['count'];
}
return $repartition;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/StatsDAOSQLite.php'
<?php
declare(strict_types=1);
class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {
#[\Override]
protected function sqlFloor(string $s): string {
return "CAST(($s) AS INT)";
}
/**
* @return array<int,int>
*/
#[\Override]
protected function calculateEntryRepartitionPerFeedPerPeriod(string $period, ?int $feed = null): array {
if ($feed) {
$restrict = "WHERE e.id_feed = {$feed}";
} else {
$restrict = '';
}
$sql = <<<SQL
SELECT strftime('{$period}', e.date, 'unixepoch') AS period
, COUNT(1) AS count
FROM `_entry` AS e
{$restrict}
GROUP BY period
ORDER BY period ASC
SQL;
$res = $this->fetchAssoc($sql);
if ($res == null) {
return [];
}
switch ($period) {
case '%H':
$periodMax = 24;
break;
case '%w':
$periodMax = 7;
break;
case '%m':
$periodMax = 12;
break;
default:
$periodMax = 30;
}
$repartition = array_fill(0, $periodMax, 0);
foreach ($res as $value) {
$repartition[(int)$value['period']] = (int)$value['count'];
}
return $repartition;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/SystemConfiguration.php'
<?php
declare(strict_types=1);
/**
* @property bool $allow_anonymous
* @property bool $allow_anonymous_refresh
* @property-read bool $allow_referrer
* @property bool $allow_robots
* @property bool $api_enabled
* @property string $archiving
* @property 'form'|'http_auth'|'none' $auth_type
* @property string $auto_update_url
* @property-read array<int,mixed> $curl_options
* @property string $default_user
* @property string $email_validation_token
* @property bool $force_email_validation
* @property-read bool $http_auth_auto_register
* @property-read string $http_auth_auto_register_email_field
* @property string $language
* @property array<string,int> $limits
* @property-read string $logo_html
* @property-read string $meta_description
* @property-read int $nb_parallel_refresh
* @property-read bool $pubsubhubbub_enabled
* @property-read string $salt
* @property-read bool $simplepie_syslog_enabled
* @property bool $unsafe_autologin_enabled
* @property array<string> $trusted_sources
* @property array<string,array<string,mixed>> $extensions
*/
final class FreshRSS_SystemConfiguration extends Minz_Configuration {
/** @throws Minz_FileNotExistException */
public static function init(string $config_filename, ?string $default_filename = null): FreshRSS_SystemConfiguration {
parent::register('system', $config_filename, $default_filename);
try {
return parent::get('system');
} catch (Minz_ConfigurationNamespaceException $ex) {
FreshRSS::killApp($ex->getMessage());
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Tag.php'
<?php
declare(strict_types=1);
class FreshRSS_Tag extends Minz_Model {
use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
private int $id = 0;
private string $name;
private int $nbEntries = -1;
private int $nbUnread = -1;
public function __construct(string $name = '') {
$this->_name($name);
}
public function id(): int {
return $this->id;
}
/**
* @param int|string $value
*/
public function _id($value): void {
$this->id = (int)$value;
}
public function name(): string {
return $this->name;
}
public function _name(string $value): void {
$this->name = trim($value);
}
public function nbEntries(): int {
if ($this->nbEntries < 0) {
$tagDAO = FreshRSS_Factory::createTagDao();
$this->nbEntries = $tagDAO->countEntries($this->id()) ?: 0;
}
return $this->nbEntries;
}
/**
* @param string|int $value
*/
public function _nbEntries($value): void {
$this->nbEntries = (int)$value;
}
public function nbUnread(): int {
if ($this->nbUnread < 0) {
$tagDAO = FreshRSS_Factory::createTagDao();
$this->nbUnread = $tagDAO->countNotRead($this->id()) ?: 0;
}
return $this->nbUnread;
}
/**
* @param string|int $value
*/
public function _nbUnread($value): void {
$this->nbUnread = (int)$value;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/TagDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_TagDAO extends Minz_ModelPdo {
public function sqlIgnore(): string {
return 'IGNORE';
}
/**
* @param array{'id'?:int,'name':string,'attributes'?:array<string,mixed>} $valuesTmp
* @return int|false
*/
public function addTag(array $valuesTmp) {
// TRIM() gives a text type hint to PostgreSQL
// No category of the same name
$sql = <<<'SQL'
INSERT INTO `_tag`(name, attributes)
SELECT * FROM (SELECT TRIM(?) as name, TRIM(?) as attributes) t2
WHERE NOT EXISTS (SELECT 1 FROM `_category` WHERE name = TRIM(?))
SQL;
$stm = $this->pdo->prepare($sql);
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$values = [
$valuesTmp['name'],
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$valuesTmp['name'],
];
if ($stm !== false && $stm->execute($values) && $stm->rowCount() > 0) {
$tagId = $this->pdo->lastInsertId('`_tag_id_seq`');
return $tagId === false ? false : (int)$tagId;
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return int|false */
public function addTagObject(FreshRSS_Tag $tag) {
$tag0 = $this->searchByName($tag->name());
if (!$tag0) {
$values = [
'name' => $tag->name(),
'attributes' => $tag->attributes(),
];
return $this->addTag($values);
}
return $tag->id();
}
/** @return int|false */
public function updateTagName(int $id, string $name) {
// No category of the same name
$sql = <<<'SQL'
UPDATE `_tag` SET name = :name1 WHERE id = :id
AND NOT EXISTS (SELECT 1 FROM `_category` WHERE name = :name2)
SQL;
$name = mb_strcut(trim($name), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->bindValue(':name1', $name, PDO::PARAM_STR) &&
$stm->bindValue(':name2', $name, PDO::PARAM_STR) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* @param array<string,mixed> $attributes
* @return int|false
*/
public function updateTagAttributes(int $id, array $attributes) {
$sql = 'UPDATE `_tag` SET attributes=:attributes WHERE id=:id';
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->bindValue(':attributes', json_encode($attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR) &&
$stm->execute()) {
return $stm->rowCount();
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
/**
* @param non-empty-string $key
* @param mixed $value
* @return int|false
*/
public function updateTagAttribute(FreshRSS_Tag $tag, string $key, $value) {
$tag->_attribute($key, $value);
return $this->updateTagAttributes($tag->id(), $tag->attributes());
}
/**
* @return int|false
*/
public function deleteTag(int $id) {
if ($id <= 0) {
return false;
}
$sql = 'DELETE FROM `_tag` WHERE id=?';
$stm = $this->pdo->prepare($sql);
$values = [$id];
if ($stm !== false && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return Traversable<array{'id':int,'name':string,'attributes'?:array<string,mixed>}> */
public function selectAll(): Traversable {
$sql = 'SELECT id, name, attributes FROM `_tag`';
$stm = $this->pdo->query($sql);
if ($stm === false) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
return;
}
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
/** @var array{'id':int,'name':string,'attributes'?:array<string,mixed>} $row */
yield $row;
}
}
/** @return Traversable<array{'id_tag':int,'id_entry':string}> */
public function selectEntryTag(): Traversable {
$sql = 'SELECT id_tag, id_entry FROM `_entrytag`';
$stm = $this->pdo->query($sql);
if ($stm === false) {
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($this->pdo->errorInfo()));
return;
}
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
FreshRSS_DatabaseDAO::pdoInt($row, ['id_tag']);
FreshRSS_DatabaseDAO::pdoString($row, ['id_entry']);
yield $row;
}
}
/** @return int|false */
public function updateEntryTag(int $oldTagId, int $newTagId) {
$sql = <<<'SQL'
DELETE FROM `_entrytag` WHERE EXISTS (
SELECT 1 FROM `_entrytag` AS e
WHERE e.id_entry = `_entrytag`.id_entry AND e.id_tag = ? AND `_entrytag`.id_tag = ?)
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm === false || !$stm->execute([$newTagId, $oldTagId])) {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
return false;
}
$sql = 'UPDATE `_entrytag` SET id_tag = ? WHERE id_tag = ?';
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute([$newTagId, $oldTagId])) {
return $stm->rowCount();
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
return false;
}
public function searchById(int $id): ?FreshRSS_Tag {
$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE id=:id', [':id' => $id]);
/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
return $res === null ? null : (current(self::daoToTags($res)) ?: null);
}
public function searchByName(string $name): ?FreshRSS_Tag {
$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE name=:name', [':name' => $name]);
/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
return $res === null ? null : (current(self::daoToTags($res)) ?: null);
}
/** @return array<int,FreshRSS_Tag>|false */
public function listTags(bool $precounts = false) {
if ($precounts) {
$sql = <<<'SQL'
SELECT t.id, t.name, count(e.id) AS unreads
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id AND e.is_read = 0
GROUP BY t.id
ORDER BY t.name
SQL;
} else {
$sql = 'SELECT * FROM `_tag` ORDER BY name';
}
$stm = $this->pdo->query($sql);
if ($stm !== false) {
$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
return self::daoToTags($res);
} else {
$info = $this->pdo->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return array<string,string> */
public function listTagsNewestItemUsec(?int $id_tag = null): array {
$sql = <<<'SQL'
SELECT t.id AS id_tag, MAX(e.id) AS newest_item_us
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id
LEFT OUTER JOIN `_entry` e ON et.id_entry = e.id
SQL;
if ($id_tag === null) {
$sql .= ' GROUP BY t.id';
} else {
$sql .= ' WHERE t.id=' . $id_tag;
}
$res = $this->fetchAssoc($sql);
if ($res == null) {
return [];
}
$newestItemUsec = [];
foreach ($res as $line) {
$newestItemUsec['t_' . $line['id_tag']] = (string)($line['newest_item_us']);
}
return $newestItemUsec;
}
public function count(): int {
$sql = 'SELECT COUNT(*) AS count FROM `_tag`';
$stm = $this->pdo->query($sql);
if ($stm !== false) {
$res = $stm->fetchAll(PDO::FETCH_ASSOC);
return (int)$res[0]['count'];
}
$info = $this->pdo->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return -1;
}
public function countEntries(int $id): int {
$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=:id_tag';
$res = $this->fetchAssoc($sql, [':id_tag' => $id]);
if ($res == null || !isset($res[0]['count'])) {
return -1;
}
return (int)$res[0]['count'];
}
public function countNotRead(?int $id = null): int {
$sql = <<<'SQL'
SELECT COUNT(*) AS count FROM `_entrytag` et
INNER JOIN `_entry` e ON et.id_entry=e.id
WHERE e.is_read=0
SQL;
$values = [];
if (null !== $id) {
$sql .= ' AND et.id_tag=:id_tag';
$values[':id_tag'] = $id;
}
$res = $this->fetchAssoc($sql, $values);
if ($res == null || !isset($res[0]['count'])) {
return -1;
}
return (int)$res[0]['count'];
}
public function tagEntry(int $id_tag, string $id_entry, bool $checked = true): bool {
if ($checked) {
$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES(?, ?)';
} else {
$sql = 'DELETE FROM `_entrytag` WHERE id_tag=? AND id_entry=?';
}
$stm = $this->pdo->prepare($sql);
$values = [$id_tag, $id_entry];
if ($stm !== false && $stm->execute($values)) {
return true;
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
/**
* @param array<array{id_tag:int,id_entry:string}> $addLabels Labels to insert as batch
* @return int|false Number of new entries or false in case of error
*/
public function tagEntries(array $addLabels) {
$hasValues = false;
$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES ';
foreach ($addLabels as $addLabel) {
$id_tag = (int)($addLabel['id_tag'] ?? 0);
$id_entry = $addLabel['id_entry'] ?? '';
if ($id_tag > 0 && ctype_digit($id_entry)) {
$sql .= "({$id_tag},{$id_entry}),";
$hasValues = true;
}
}
$sql = rtrim($sql, ',');
if (!$hasValues) {
return false;
}
$affected = $this->pdo->exec($sql);
if ($affected !== false) {
return $affected;
}
$info = $this->pdo->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
/**
* @return array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}>|false
*/
public function getTagsForEntry(string $id_entry) {
$sql = <<<'SQL'
SELECT t.id, t.name, et.id_entry IS NOT NULL as checked
FROM `_tag` t
LEFT OUTER JOIN `_entrytag` et ON et.id_tag = t.id AND et.id_entry=?
ORDER BY t.name
SQL;
$stm = $this->pdo->prepare($sql);
$values = [$id_entry];
if ($stm !== false && $stm->execute($values)) {
$lines = $stm->fetchAll(PDO::FETCH_ASSOC);
for ($i = count($lines) - 1; $i >= 0; $i--) {
$lines[$i]['id'] = (int)($lines[$i]['id']);
$lines[$i]['checked'] = !empty($lines[$i]['checked']);
}
return $lines;
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
/**
* @param array<FreshRSS_Entry|numeric-string|array<string,string>> $entries
* @return array<array{'id_entry':string,'id_tag':int,'name':string}>|false
*/
public function getTagsForEntries(array $entries) {
$sql = <<<'SQL'
SELECT et.id_entry, et.id_tag, t.name
FROM `_tag` t
INNER JOIN `_entrytag` et ON et.id_tag = t.id
SQL;
$values = [];
if (count($entries) > 0) {
if (count($entries) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
// Split a query with too many variables parameters
$idsChunks = array_chunk($entries, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
foreach ($idsChunks as $idsChunk) {
$valuesChunk = $this->getTagsForEntries($idsChunk);
if (!is_array($valuesChunk)) {
return false;
}
$values = array_merge($values, $valuesChunk);
}
return $values;
}
$sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1) . '?)';
if (is_array($entries[0])) {
/** @var array<array<string,string>> $entries */
foreach ($entries as $entry) {
if (!empty($entry['id'])) {
$values[] = $entry['id'];
}
}
} elseif (is_object($entries[0])) {
/** @var array<FreshRSS_Entry> $entries */
foreach ($entries as $entry) {
$values[] = $entry->id();
}
} else {
/** @var array<numeric-string> $entries */
foreach ($entries as $entry) {
$values[] = $entry;
}
}
}
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute($values)) {
return $stm->fetchAll(PDO::FETCH_ASSOC);
}
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
/**
* Produces an array: for each entry ID (prefixed by `e_`), associate a list of labels.
* Used by API and by JSON export, to speed up queries (would be very expensive to perform a label look-up on each entry individually).
* @param array<FreshRSS_Entry|numeric-string> $entries the list of entries for which to retrieve the labels.
* @return array<string,array<string>> An array of the shape `[e_id_entry => ["label 1", "label 2"]]`
*/
public function getEntryIdsTagNames(array $entries): array {
$result = [];
foreach ($this->getTagsForEntries($entries) ?: [] as $line) {
$entryId = 'e_' . $line['id_entry'];
$tagName = $line['name'];
if (empty($result[$entryId])) {
$result[$entryId] = [];
}
$result[$entryId][] = $tagName;
}
return $result;
}
/**
* @param iterable<array{'id':int,'name':string,'attributes'?:string}> $listDAO
* @return array<int,FreshRSS_Tag>
*/
private static function daoToTags(iterable $listDAO): array {
$list = [];
foreach ($listDAO as $dao) {
if (empty($dao['id']) || empty($dao['name'])) {
continue;
}
$tag = new FreshRSS_Tag($dao['name']);
$tag->_id($dao['id']);
if (!empty($dao['attributes'])) {
$tag->_attributes($dao['attributes']);
}
if (isset($dao['unreads'])) {
$tag->_nbUnread($dao['unreads']);
}
$list[$tag->id()] = $tag;
}
return $list;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/TagDAOPGSQL.php'
<?php
declare(strict_types=1);
class FreshRSS_TagDAOPGSQL extends FreshRSS_TagDAO {
#[\Override]
public function sqlIgnore(): string {
return ''; //TODO
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/TagDAOSQLite.php'
<?php
declare(strict_types=1);
class FreshRSS_TagDAOSQLite extends FreshRSS_TagDAO {
#[\Override]
public function sqlIgnore(): string {
return 'OR IGNORE';
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/Themes.php'
<?php
declare(strict_types=1);
class FreshRSS_Themes extends Minz_Model {
private static string $themesUrl = '/themes/';
private static string $defaultIconsUrl = '/themes/icons/';
public static string $defaultTheme = 'Origine';
/** @return array<string> */
public static function getList(): array {
return array_values(array_diff(
scandir(PUBLIC_PATH . self::$themesUrl) ?: [],
['..', '.']
));
}
/** @return array<string,array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}> */
public static function get(): array {
$themes_list = self::getList();
$list = [];
foreach ($themes_list as $theme_dir) {
$theme = self::get_infos($theme_dir);
if ($theme) {
$list[$theme_dir] = $theme;
}
}
return $list;
}
/**
* @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
*/
public static function get_infos(string $theme_id) {
$theme_dir = PUBLIC_PATH . self::$themesUrl . $theme_id;
if (is_dir($theme_dir)) {
$json_filename = $theme_dir . '/metadata.json';
if (file_exists($json_filename)) {
$content = file_get_contents($json_filename) ?: '';
$res = json_decode($content, true);
if (is_array($res) &&
!empty($res['name']) &&
isset($res['files']) &&
is_array($res['files'])) {
$res['id'] = $theme_id;
/** @var array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}} */
return $res;
}
}
}
return false;
}
private static string $themeIconsUrl;
/** @var array<string,int> */
private static array $themeIcons;
/**
* @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
*/
public static function load(string $theme_id) {
$infos = self::get_infos($theme_id);
if (!$infos) {
if ($theme_id !== self::$defaultTheme) { //Fall-back to default theme
return self::load(self::$defaultTheme);
}
$themes_list = self::getList();
if (!empty($themes_list)) {
if ($theme_id !== $themes_list[0]) { //Fall-back to first theme
return self::load($themes_list[0]);
}
}
return false;
}
self::$themeIconsUrl = self::$themesUrl . $theme_id . '/icons/';
self::$themeIcons = is_dir(PUBLIC_PATH . self::$themeIconsUrl) ? array_fill_keys(array_diff(
scandir(PUBLIC_PATH . self::$themeIconsUrl) ?: [],
['..', '.']
), 1) : [];
return $infos;
}
public static function title(string $name): string {
static $titles = [
'opml-dyn' => 'sub.category.dynamic_opml',
];
return $titles[$name] ?? '';
}
public static function alt(string $name): string {
static $alts = [
'add' => 'β', //β
'all' => 'β°',
'bookmark-add' => 'β', //β
'bookmark-tag' => 'π',
'category' => 'ποΈ', //β·
'close' => 'β',
'configure' => 'βοΈ',
'debug' => 'π',
'down' => 'π½', //β½
'error' => 'β',
'favorite' => 'β', //β
'FreshRSS-logo' => 'β',
'help' => 'βΉοΈ', //β
'icon' => 'β',
'important' => 'π',
'key' => 'π', //βΏ
'label' => 'π·οΈ',
'link' => 'βοΈ', //β
'look' => 'π', //π
'login' => 'π',
'logout' => 'π',
'next' => 'β©',
'non-starred' => 'β',
'notice' => 'βΉοΈ', //β
'opml-dyn' => 'β‘',
'prev' => 'βͺ',
'read' => 'βοΈ', //β
'rss' => 'π£', //β
'unread' => 'π²', //β
'refresh' => 'π', //β»
'search' => 'π',
'share' => 'β»οΈ', //βΊ
'sort-down' => 'β¬οΈ', //β
'sort-up' => 'β¬οΈ', //β
'starred' => 'β', //β
'stats' => 'π', //%
'tag' => 'π', //β
'up' => 'πΌ', //β³
'view-normal' => 'π°', //β°
'view-global' => 'π', //β·
'view-reader' => 'π',
'warning' => 'β οΈ', //β³
];
return $alts[$name] ?? '';
}
// TODO: Change for enum in PHP 8.1+
public const ICON_DEFAULT = 0;
public const ICON_IMG = 1;
public const ICON_URL = 2;
public const ICON_EMOJI = 3;
public static function icon(string $name, int $type = self::ICON_DEFAULT): string {
$alt = self::alt($name);
if ($alt == '') {
return '';
}
$url = $name . '.svg';
$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
$title = self::title($name);
if ($title != '') {
$title = ' title="' . _t($title) . '"';
}
if ($type == self::ICON_DEFAULT) {
if ((FreshRSS_Context::hasUserConf() && FreshRSS_Context::userConf()->icons_as_emojis)
// default to emoji alternate for some icons
) {
$type = self::ICON_EMOJI;
} else {
$type = self::ICON_IMG;
}
}
switch ($type) {
case self::ICON_URL:
return Minz_Url::display($url);
case self::ICON_IMG:
return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '"' . $title . ' />';
case self::ICON_EMOJI:
default:
return '<span class="icon"' . $title . '>' . $alt . '</span>';
}
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/UserConfiguration.php'
<?php
declare(strict_types=1);
/**
* @property string $apiPasswordHash
* @property array{'keep_period':string|false,'keep_max':int|false,'keep_min':int|false,'keep_favourites':bool,'keep_labels':bool,'keep_unreads':bool} $archiving
* @property bool $auto_load_more
* @property bool $auto_remove_article
* @property bool $bottomline_date
* @property bool $bottomline_favorite
* @property bool $bottomline_link
* @property bool $bottomline_read
* @property bool $bottomline_sharing
* @property bool $bottomline_tags
* @property bool $bottomline_myLabels
* @property string $content_width
* @property-read int $default_state
* @property string $default_view
* @property string|bool $display_categories
* @property string $show_tags
* @property int $show_tags_max
* @property string $show_author_date
* @property string $show_feed_name
* @property string $show_article_icons
* @property bool $display_posts
* @property string $email_validation_token
* @property-read bool $enabled
* @property string $feverKey
* @property bool $hide_read_feeds
* @property int $html5_notif_timeout
* @property-read bool $is_admin
* @property int|null $keep_history_default
* @property string $language
* @property string $timezone
* @property bool $lazyload
* @property string $mail_login
* @property bool $mark_updated_article_unread
* @property array<string,bool|int> $mark_when
* @property int $max_posts_per_rss
* @property-read array<string,int> $limits
* @property int|null $old_entries
* @property bool $onread_jump_next
* @property string $passwordHash
* @property int $posts_per_page
* @property array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}> $queries
* @property bool $reading_confirm
* @property int $since_hours_posts_per_rss
* @property bool $show_fav_unread
* @property bool $show_favicons
* @property bool $icons_as_emojis
* @property int $simplify_over_n_feeds
* @property bool $show_nav_buttons
* @property 'ASC'|'DESC' $sort_order
* @property array<string,array<string>> $sharing
* @property array<string,string> $shortcuts
* @property bool $sides_close_article
* @property bool $sticky_post
* @property string $theme
* @property string $darkMode
* @property string $token
* @property bool $topline_date
* @property bool $topline_display_authors
* @property bool $topline_favorite
* @property bool $topline_sharing
* @property bool $topline_link
* @property bool $topline_read
* @property bool $topline_summary
* @property string $topline_website
* @property string $topline_thumbnail
* @property int $ttl_default
* @property int $dynamic_opml_ttl_default
* @property-read bool $unsafe_autologin_enabled
* @property string $view_mode
* @property array<string,bool|int|string> $volatile
* @property array<string,array<string,mixed>> $extensions
*/
final class FreshRSS_UserConfiguration extends Minz_Configuration {
use FreshRSS_FilterActionsTrait;
/** @throws Minz_FileNotExistException */
public static function init(string $config_filename, ?string $default_filename = null): FreshRSS_UserConfiguration {
parent::register('user', $config_filename, $default_filename);
try {
return parent::get('user');
} catch (Minz_ConfigurationNamespaceException $ex) {
FreshRSS::killApp($ex->getMessage());
}
}
/**
* Access the default configuration for users.
* @throws Minz_FileNotExistException
*/
public static function default(): FreshRSS_UserConfiguration {
static $default_user_conf = null;
if ($default_user_conf == null) {
$namespace = 'user_default';
FreshRSS_UserConfiguration::register($namespace, '_', FRESHRSS_PATH . '/config-user.default.php');
$default_user_conf = FreshRSS_UserConfiguration::get($namespace);
}
return $default_user_conf;
}
/**
* @param non-empty-string $key
* @return array<int|string,mixed>|null
*/
public function attributeArray(string $key): ?array {
$a = parent::param($key, null);
return is_array($a) ? $a : null;
}
/** @param non-empty-string $key */
public function attributeBool(string $key): ?bool {
$a = parent::param($key, null);
return is_bool($a) ? $a : null;
}
/** @param non-empty-string $key */
public function attributeInt(string $key): ?int {
$a = parent::param($key, null);
return is_numeric($a) ? (int)$a : null;
}
/** @param non-empty-string $key */
public function attributeString(string $key): ?string {
$a = parent::param($key, null);
return is_string($a) ? $a : null;
}
/**
* @param non-empty-string $key
* @param array<string,mixed>|mixed|null $value Value, not HTML-encoded
*/
public function _attribute(string $key, $value = null): void {
parent::_param($key, $value);
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/UserDAO.php'
<?php
declare(strict_types=1);
class FreshRSS_UserDAO extends Minz_ModelPdo {
public function createUser(): bool {
require(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
try {
$sql = $GLOBALS['SQL_CREATE_TABLES'];
$ok = $this->pdo->exec($sql) !== false; //Note: Only exec() can take multiple statements safely.
} catch (Exception $e) {
$ok = false;
Minz_Log::error('Error while creating database for user ' . $this->current_user . ': ' . $e->getMessage());
}
if ($ok) {
return true;
} else {
$info = $this->pdo->errorInfo();
Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
return false;
}
}
public function deleteUser(): bool {
if (defined('STDERR')) {
fwrite(STDERR, 'Deleting SQL data for user β' . $this->current_user . "ββ¦\n");
}
require(APP_PATH . '/SQL/install.sql.' . $this->pdo->dbType() . '.php');
$ok = $this->pdo->exec($GLOBALS['SQL_DROP_TABLES']) !== false;
if ($ok) {
$this->close();
return true;
} else {
$info = $this->pdo->errorInfo();
Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
return false;
}
}
public static function exists(string $username): bool {
return is_dir(USERS_PATH . '/' . $username);
}
/** Update time of the last modification action by the user (e.g., mark an article as read) */
public static function touch(string $username = ''): bool {
if ($username === '') {
$username = Minz_User::name() ?? Minz_User::INTERNAL_USER;
} elseif (!FreshRSS_user_Controller::checkUsername($username)) {
return false;
}
return touch(USERS_PATH . '/' . $username . '/config.php');
}
/** Time of the last modification action by the user (e.g., mark an article as read) */
public static function mtime(string $username): int {
return @filemtime(USERS_PATH . '/' . $username . '/config.php') ?: 0;
}
/** Update time of the last new content automatically received by the user (e.g., cron job, WebSub) */
public static function ctouch(string $username = ''): bool {
if ($username === '') {
$username = Minz_User::name() ?? Minz_User::INTERNAL_USER;
} elseif (!FreshRSS_user_Controller::checkUsername($username)) {
return false;
}
return touch(USERS_PATH . '/' . $username . '/' . LOG_FILENAME);
}
/** Time of the last new content automatically received by the user (e.g., cron job, WebSub) */
public static function ctime(string $username): int {
return @filemtime(USERS_PATH . '/' . $username . '/' . LOG_FILENAME) ?: 0;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/UserQuery.php'
<?php
declare(strict_types=1);
/**
* Contains the description of a user query
*
* It allows to extract the meaningful bits of the query to be manipulated in an
* easy way.
*/
class FreshRSS_UserQuery {
private bool $deprecated = false;
private string $get = '';
private string $get_name = '';
private string $get_type = '';
private string $name = '';
private string $order = '';
private FreshRSS_BooleanSearch $search;
private int $state = 0;
private string $url = '';
private string $token = '';
private bool $shareRss = false;
private bool $shareOpml = false;
/** @var array<int,FreshRSS_Category> $categories */
private array $categories;
/** @var array<int,FreshRSS_Tag> $labels */
private array $labels;
private string $description = '';
private string $imageUrl = '';
public static function generateToken(string $salt): string {
if (!FreshRSS_Context::hasSystemConf()) {
return '';
}
$hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16));
if (function_exists('gmp_init')) {
// Shorten the hash if possible by converting from base 16 to base 62
$hash = gmp_strval(gmp_init($hash, 16), 62);
}
return $hash;
}
/**
* @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
* shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string} $query
* @param array<int,FreshRSS_Category> $categories
* @param array<int,FreshRSS_Tag> $labels
*/
public function __construct(array $query, array $categories, array $labels) {
$this->categories = $categories;
$this->labels = $labels;
if (isset($query['get'])) {
$this->parseGet($query['get']);
} else {
$this->get_type = 'all';
}
if (isset($query['name'])) {
$this->name = trim($query['name']);
}
if (isset($query['order'])) {
$this->order = $query['order'];
}
if (empty($query['url'])) {
if (!empty($query)) {
$link = $query;
unset($link['description']);
unset($link['imageUrl']);
unset($link['name']);
unset($link['shareOpml']);
unset($link['shareRss']);
$this->url = Minz_Url::display(['params' => $link]);
}
} else {
$this->url = $query['url'];
}
if (!isset($query['search'])) {
$query['search'] = '';
}
if (!empty($query['token'])) {
$this->token = $query['token'];
}
if (isset($query['shareRss'])) {
$this->shareRss = $query['shareRss'];
}
if (isset($query['shareOpml'])) {
$this->shareOpml = $query['shareOpml'];
}
if (isset($query['description'])) {
$this->description = $query['description'];
}
if (isset($query['imageUrl'])) {
$this->imageUrl = $query['imageUrl'];
}
// linked too deeply with the search object, need to use dependency injection
$this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', false);
if (!empty($query['state'])) {
$this->state = intval($query['state']);
}
}
/**
* Convert the current object to an array.
*
* @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}
*/
public function toArray(): array {
return array_filter([
'get' => $this->get,
'name' => $this->name,
'order' => $this->order,
'search' => $this->search->getRawInput(),
'state' => $this->state,
'url' => $this->url,
'token' => $this->token,
'shareRss' => $this->shareRss,
'shareOpml' => $this->shareOpml,
'description' => $this->description,
'imageUrl' => $this->imageUrl,
]);
}
/**
* Parse the get parameter in the query string to extract its name and type
*/
private function parseGet(string $get): void {
$this->get = $get;
if ($this->get === '') {
$this->get_type = 'all';
} elseif (preg_match('/(?P<type>[acfistT])(_(?P<id>\d+))?/', $get, $matches)) {
$id = intval($matches['id'] ?? '0');
switch ($matches['type']) {
case 'a':
$this->get_type = 'all';
break;
case 'c':
$this->get_type = 'category';
$c = $this->categories[$id] ?? null;
$this->get_name = $c === null ? '' : $c->name();
break;
case 'f':
$this->get_type = 'feed';
$f = FreshRSS_Category::findFeed($this->categories, $id);
$this->get_name = $f === null ? '' : $f->name();
break;
case 'i':
$this->get_type = 'important';
break;
case 's':
$this->get_type = 'favorite';
break;
case 't':
$this->get_type = 'label';
$l = $this->labels[$id] ?? null;
$this->get_name = $l === null ? '' : $l->name();
break;
case 'T':
$this->get_type = 'all_labels';
break;
}
if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) {
$this->deprecated = true;
}
}
}
/**
* Check if the current user query is deprecated.
* It is deprecated if the category or the feed used in the query are
* not existing.
*/
public function isDeprecated(): bool {
return $this->deprecated;
}
/**
* Check if the user query has parameters.
*/
public function hasParameters(): bool {
if ($this->get_type !== 'all') {
return true;
}
if ($this->hasSearch()) {
return true;
}
if (!in_array($this->state, [
0,
FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ,
FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE
], true)) {
return true;
}
if ($this->order !== '' && $this->order !== FreshRSS_Context::userConf()->sort_order) {
return true;
}
return false;
}
/**
* Check if there is a search in the search object
*/
public function hasSearch(): bool {
return $this->search->getRawInput() !== '';
}
public function getGet(): string {
return $this->get;
}
public function getGetName(): string {
return $this->get_name;
}
public function getGetType(): string {
return $this->get_type;
}
public function getName(): string {
return $this->name;
}
public function getOrder(): string {
return $this->order ?: FreshRSS_Context::userConf()->sort_order;
}
public function getSearch(): FreshRSS_BooleanSearch {
return $this->search;
}
public function getState(): int {
$state = $this->state;
if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) {
$state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ;
}
if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
$state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE;
}
return $state;
}
public function getUrl(): string {
return $this->url;
}
public function getToken(): string {
return $this->token;
}
public function setToken(string $token): void {
$this->token = $token;
}
public function setShareRss(bool $shareRss): void {
$this->shareRss = $shareRss;
}
public function shareRss(): bool {
return $this->shareRss;
}
public function setShareOpml(bool $shareOpml): void {
$this->shareOpml = $shareOpml;
}
public function shareOpml(): bool {
return $this->shareOpml;
}
protected function sharedUrl(bool $xmlEscaped = true): string {
$currentUser = Minz_User::name() ?? '';
return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true);
}
public function sharedUrlRss(bool $xmlEscaped = true): string {
if ($this->shareRss && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=rss';
}
return '';
}
public function sharedUrlGreader(bool $xmlEscaped = true): string {
if ($this->shareRss && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=greader';
}
return '';
}
public function sharedUrlHtml(bool $xmlEscaped = true): string {
if ($this->shareRss && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=html';
}
return '';
}
/**
* OPML is only safe for some query types, otherwise it risks leaking unwanted feed information.
*/
public function safeForOpml(): bool {
return in_array($this->get_type, ['all', 'category', 'feed'], true);
}
public function sharedUrlOpml(bool $xmlEscaped = true): string {
if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=opml';
}
return '';
}
public function getDescription(): string {
return $this->description;
}
public function setDescription(string $description): void {
$this->description = $description;
}
public function getImageUrl(): string {
return $this->imageUrl;
}
public function setImageUrl(string $imageUrl): void {
$this->imageUrl = $imageUrl;
}
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/View.php'
<?php
declare(strict_types=1);
class FreshRSS_View extends Minz_View {
// Main views
/** @var callable */
public $callbackBeforeEntries;
/** @var callable|null */
public $callbackBeforeFeeds;
/** @var callable */
public $callbackBeforePagination;
/** @var array<int,FreshRSS_Category> */
public array $categories;
public ?FreshRSS_Category $category;
public ?FreshRSS_Tag $tag;
public string $current_user;
/** @var iterable<FreshRSS_Entry> */
public $entries;
public FreshRSS_Entry $entry;
public FreshRSS_Feed $feed;
/** @var array<int,FreshRSS_Feed> */
public array $feeds;
public int $nbUnreadTags;
/** @var array<int,FreshRSS_Tag> */
public array $tags;
/** @var array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}> */
public array $tagsForEntry;
/** @var array<string,array<string>> */
public array $tagsForEntries;
public bool $excludeMutedFeeds;
// Substriptions
public bool $displaySlider = false;
public bool $load_ok;
public bool $onlyFeedsWithError;
public bool $signalError;
// Manage users
/** @var 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} */
public array $details;
public bool $disable_aside;
public bool $show_email_field;
public string $username;
/** @var array<array{'language':string,'enabled':bool,'is_admin':bool,'enabled':bool,'article_count':int,'database_size':int,'last_user_activity':string,'mail_login':string,'feed_count':int,'is_default':bool}> */
public array $users;
// Updates
public string $last_update_time;
/** @var array<string,bool> */
public array $status_files;
/** @var array<string,bool> */
public array $status_php;
public bool $update_to_apply;
/** @var array<string,bool> */
public array $status_database;
public bool $is_release_channel_stable;
// Archiving
public int $nb_total;
public int $size_total;
public int $size_user;
// Display
/** @var array<string,array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}> */
public array $themes;
// Shortcuts
/** @var array<int, string> */
public array $list_keys;
// User queries
/** @var array<int,FreshRSS_UserQuery> */
public array $queries;
/** @var FreshRSS_UserQuery|null */
public ?FreshRSS_UserQuery $query = null;
// Export / Import
public string $content;
/** @var array<string,array<string>> */
public array $entryIdsTagNames;
public string $list_title;
public int $queryId;
public string $type;
// Form login
public int $cookie_days;
// Registration
public bool $can_register;
public string $preferred_language;
public bool $show_tos_checkbox;
public string $terms_of_service;
public string $site_title;
public string $validation_url;
// Logs
public int $currentPage;
public Minz_Paginator $logsPaginator;
public int $nbPage;
// RSS view
public FreshRSS_UserQuery $userQuery;
public string $html_url = '';
public string $rss_title = '';
public string $rss_url = '';
public string $rss_base = '';
public bool $internal_rendering = false;
public string $description = '';
public string $image_url = '';
// Content preview
public string $fatalError;
public string $htmlContent;
public bool $selectorSuccess;
// Extensions
/** @var array<array{'name':string,'author':string,'description':string,'version':string,'entrypoint':string,'type':'system'|'user','url':string,'method':string,'directory':string}> */
public array $available_extensions;
public ?Minz_Extension $ext_details;
/** @var array{'system':array<Minz_Extension>,'user':array<Minz_Extension>} */
public array $extension_list;
public ?Minz_Extension $extension;
/** @var array<string,string> */
public array $extensions_installed;
// Errors
public string $code;
public string $errorMessage;
/** @var array<string,string> */
public array $message;
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/ViewJavascript.php'
<?php
declare(strict_types=1);
final class FreshRSS_ViewJavascript extends FreshRSS_View {
/** @var array<int,FreshRSS_Category> */
public array $categories;
/** @var array<int,FreshRSS_Feed> */
public array $feeds;
/** @var array<int,FreshRSS_Tag> */
public array $tags;
public string $nonce;
public string $salt1;
}
wget 'https://sme10.lists2.roe3.org/FreshRSS/app/Models/ViewStats.php'
<?php
declare(strict_types=1);
final class FreshRSS_ViewStats extends FreshRSS_View {
/** @var array<int,FreshRSS_Category> */
public array $categories;
public FreshRSS_Feed $feed;
/** @var array<int,FreshRSS_Feed> */
public array $feeds;
public bool $displaySlider = false;
public float $average;
public float $averageDayOfWeek;
public float $averageHour;
public float $averageMonth;
/** @var array<string> */
public array $days;
/** @var array<string,array<int,int|string>> */
public array $entryByCategory;
/** @var array<int,int> */
public array $entryCount;
/** @var array<string,array<int,int|string>> */
public array $feedByCategory;
/** @var array<int, string> */
public array $hours24Labels;
/** @var array<string,array<int,array<string,int|string>>> */
public array $idleFeeds;
/** @var array<int,string> */
public array $last30DaysLabel;
/** @var array<int,string> */
public array $last30DaysLabels;
/** @var array<string,string> */
public array $months;
/** @var array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false */
public $repartition;
/** @var array{'main_stream':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false,'all_feeds':array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false} */
public array $repartitions;
/** @var array<int,int> */
public array $repartitionDayOfWeek;
/** @var array<string,int>|array<int,int> */
public array $repartitionHour;
/** @var array<int,int> */
public array $repartitionMonth;
/** @var array<array{'id':int,'name':string,'category':string,'count':int}> */
public array $topFeed;
}