Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
19020b6
PHPStan 2.0
Alkarex Dec 22, 2024
693e3c2
More
Alkarex Dec 22, 2024
aad0925
Merge branch 'edge' into phpstan-2.0
Alkarex Dec 23, 2024
10d0bc4
More
Alkarex Dec 23, 2024
5765bb7
Done
Alkarex Dec 23, 2024
c90a99e
fix i18n CLI
Alkarex Dec 23, 2024
7c60166
Merge branch 'edge' into phpstan-2.0
Alkarex Dec 23, 2024
a45e1d8
Restore a PHPStan Next test
Alkarex Dec 24, 2024
62117c6
4 more on Level 10
Alkarex Dec 24, 2024
7279949
fix getTagsForEntry
Alkarex Dec 24, 2024
586f44e
API at Level 10
Alkarex Dec 24, 2024
9cab755
More Level 10
Alkarex Dec 24, 2024
524105e
Finish Minz at Level 10
Alkarex Dec 24, 2024
8ac3b10
Finish CLI at Level 10
Alkarex Dec 24, 2024
97ca771
Finish Controllers at Level 10
Alkarex Dec 24, 2024
18b2685
More Level 10
Alkarex Dec 24, 2024
615d25f
More
Alkarex Dec 24, 2024
0b54b0d
Pass bleedingEdge
Alkarex Dec 24, 2024
6acedc1
Clean PHPStan options and add TODOs
Alkarex Dec 25, 2024
da6be49
Level 10 for main config
Alkarex Dec 25, 2024
c87150f
More
Alkarex Dec 25, 2024
d35a972
Consitency array vs. list
Alkarex Dec 25, 2024
9d5d094
Sanitize themes get_infos
Alkarex Dec 25, 2024
7822538
Simplify TagDAO->getTagsForEntries()
Alkarex Dec 25, 2024
89ef671
Finish reportAnyTypeWideningInVarTag
Alkarex Dec 25, 2024
de37a28
Prepare checkBenevolentUnionTypes and checkImplicitMixed
Alkarex Dec 25, 2024
040cb17
Merge branch 'edge' into phpstan-2.0
Alkarex Dec 26, 2024
7f1567f
Fixes
Alkarex Dec 26, 2024
d9974ee
Refix
Alkarex Dec 26, 2024
3332629
Another fix
Alkarex Dec 26, 2024
5ef0234
Casing of __METHOD__ constant
Alkarex Dec 26, 2024
35b95e4
Merge branch 'edge' into phpstan-2.0
Alkarex Dec 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- name: PHPStan
run: composer run-script phpstan

- name: PHPStan Next
run: composer run-script phpstan-next

# NPM tests

- name: Uses Node.js
Expand Down
2 changes: 0 additions & 2 deletions app/Controllers/categoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,6 @@ public function deleteAction(): void {
}

// Remove related queries.
/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries */
$queries = remove_query_by_get('c_' . $id, FreshRSS_Context::userConf()->queries);
FreshRSS_Context::userConf()->queries = $queries;
FreshRSS_Context::userConf()->save();
Expand Down Expand Up @@ -239,7 +238,6 @@ public function emptyAction(): void {

// Remove related queries
foreach ($feeds as $feed) {
/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> */
$queries = remove_query_by_get('f_' . $feed->id(), FreshRSS_Context::userConf()->queries);
FreshRSS_Context::userConf()->queries = $queries;
}
Expand Down
22 changes: 15 additions & 7 deletions app/Controllers/configureController.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,17 @@ public function integrationAction(): void {
FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));

if (Minz_Request::isPost()) {
$params = $_POST;
FreshRSS_Context::userConf()->sharing = $params['share'];
FreshRSS_Context::userConf()->save();
invalidateHttpCache();
$share = $_POST['share'] ?? null;
if (is_array($share)) {
$share = array_filter($share, fn($value, $key): bool =>
is_string($key) && is_array($value) &&
is_array_values_string($value),
ARRAY_FILTER_USE_BOTH);
/** @var array<string,array<string,string>> $share */
FreshRSS_Context::userConf()->sharing = $share;
FreshRSS_Context::userConf()->save();
invalidateHttpCache();
}

Minz_Request::good(_t('feedback.conf.updated'), [ 'c' => 'configure', 'a' => 'integration' ]);
}
Expand Down Expand Up @@ -308,7 +315,7 @@ public function queriesAction(): void {
FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));

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

$queries = [];
Expand Down Expand Up @@ -390,7 +397,7 @@ public function queryAction(): void {
$queryParams['search'] = htmlspecialchars_decode($params['search'], ENT_QUOTES);
}
if (!empty($params['state']) && is_array($params['state'])) {
$queryParams['state'] = (int)array_sum($params['state']);
$queryParams['state'] = (int)array_sum(array_map('intval', $params['state']));
}
if (empty($params['token']) || !is_string($params['token'])) {
$queryParams['token'] = FreshRSS_UserQuery::generateToken($name);
Expand Down Expand Up @@ -453,9 +460,10 @@ public function bookmarkQueryAction(): void {
foreach (FreshRSS_Context::userConf()->queries as $key => $query) {
$queries[$key] = (new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
}
$params = $_GET;
$params = array_filter($_GET, 'is_string', ARRAY_FILTER_USE_KEY);
unset($params['name']);
unset($params['rid']);
/** @var array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string} $params */
$params['url'] = Minz_Url::display(['params' => $params]);
$params['name'] = _t('conf.query.number', count($queries) + 1);
$queries[] = (new FreshRSS_UserQuery($params, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
Expand Down
4 changes: 2 additions & 2 deletions app/Controllers/entryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ public function readAction(): void {
}
}
} else {
/** @var array<numeric-string> $idArray */
/** @var list<numeric-string> $idArray */
$idArray = Minz_Request::paramArrayString('id');
$idString = Minz_Request::paramString('id');
if (count($idArray) > 0) {
Expand All @@ -177,7 +177,7 @@ public function readAction(): void {
$tagsForEntries = $tagDAO->getTagsForEntries($ids) ?: [];
$tags = [];
foreach ($tagsForEntries as $line) {
$tags['t_' . $line['id_tag']][] = $line['id_entry'];
$tags['t_' . $line['id_tag']][] = (string)$line['id_entry'];
}
$this->view->tagsForEntries = $tags;
}
Expand Down
15 changes: 11 additions & 4 deletions app/Controllers/extensionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public function indexAction(): void {
}

/**
* fetch extension list from GitHub
* @return array<array{'name':string,'author':string,'description':string,'version':string,'entrypoint':string,'type':'system'|'user','url':string,'method':string,'directory':string}>
* Fetch extension list from GitHub
* @return list<array{name:string,author:string,description:string,version:string,entrypoint:string,type:'system'|'user',url:string,method:string,directory:string}>
*/
protected function getAvailableExtensionList(): array {
$extensionListUrl = 'https://raw.githubusercontent.com/FreshRSS/Extensions/master/extensions.json';
Expand Down Expand Up @@ -76,17 +76,24 @@ protected function getAvailableExtensionList(): array {
// the current implementation for now, unless it becomes too much effort maintain the extension list manually
$extensions = [];
foreach ($list['extensions'] as $extension) {
if (!is_array($extension)) {
continue;
}
if (isset($extension['version']) && is_numeric($extension['version'])) {
$extension['version'] = (string)$extension['version'];
}
foreach (['author', 'description', 'directory', 'entrypoint', 'method', 'name', 'type', 'url', 'version'] as $key) {
if (empty($extension[$key]) || !is_string($extension[$key])) {
$keys = ['author', 'description', 'directory', 'entrypoint', 'method', 'name', 'type', 'url', 'version'];
$extension = array_intersect_key($extension, array_flip($keys)); // Keep only valid keys
$extension = array_filter($extension, 'is_string');
foreach ($keys as $key) {
if (empty($extension[$key])) {
continue 2;
}
}
if (!in_array($extension['type'], ['system', 'user'], true)) {
continue;
}
/** @var array{name:string,author:string,description:string,version:string,entrypoint:string,type:'system'|'user',url:string,method:string,directory:string} $extension */
$extensions[] = $extension;
}
return $extensions;
Expand Down
2 changes: 0 additions & 2 deletions app/Controllers/feedController.php
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,6 @@ private static function applyLabelActions(int $nbNewEntries): int|false {
}

$entryDAO = FreshRSS_Factory::createEntryDao();
/** @var array<array{id_tag:int,id_entry:string}> $applyLabels */
$applyLabels = [];
foreach (FreshRSS_Entry::fromTraversable($entryDAO->selectAll($nbNewEntries)) as $entry) {
foreach ($labels as $label) {
Expand Down Expand Up @@ -1003,7 +1002,6 @@ public static function deleteFeed(int $feed_id): bool {
// TODO: Delete old favicon

// Remove related queries
/** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries */
$queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries);
FreshRSS_Context::userConf()->queries = $queries;
FreshRSS_Context::userConf()->save();
Expand Down
90 changes: 52 additions & 38 deletions app/Controllers/importExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,13 @@ public function importAction(): void {
Minz_Request::forward(['c' => 'importExport', 'a' => 'index'], true);
}

$file = $_FILES['file'];
$status_file = $file['error'];
$file = $_FILES['file'] ?? null;
$status_file = is_array($file) ? $file['error'] ?? -1 : -1;

if ($status_file !== 0) {
Minz_Log::warning('File cannot be uploaded. Error code: ' . $status_file);
if (!is_array($file) || $status_file !== 0 || !is_string($file['name'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
Minz_Log::warning('File cannot be uploaded. Error code: ' . (is_numeric($status_file) ? $status_file : -1));
Minz_Request::bad(_t('feedback.import_export.file_cannot_be_uploaded'), [ 'c' => 'importExport', 'a' => 'index' ]);
return;
}

if (function_exists('set_time_limit')) {
Expand Down Expand Up @@ -232,33 +233,36 @@ private static function guessFileType(string $filename): string {
private function ttrssXmlToJson(string $xml): string|false {
$table = (array)simplexml_load_string($xml, options: LIBXML_NOBLANKS | LIBXML_NOCDATA);
$table['items'] = $table['article'] ?? [];
if (!is_array($table['items'])) {
$table['items'] = [];
}
unset($table['article']);
for ($i = count($table['items']) - 1; $i >= 0; $i--) {
$item = (array)($table['items'][$i]);
$item = array_filter($item, static fn($v) =>
// Filter out empty properties, potentially reported as empty objects
(is_string($v) && trim($v) !== '') || !empty($v));
$item['updated'] = isset($item['updated']) ? strtotime($item['updated']) : '';
$item['updated'] = is_string($item['updated'] ?? null) ? strtotime($item['updated']) : '';
$item['published'] = $item['updated'];
$item['content'] = ['content' => $item['content'] ?? ''];
$item['categories'] = isset($item['tag_cache']) ? [$item['tag_cache']] : [];
$item['categories'] = is_string($item['tag_cache'] ?? null) ? [$item['tag_cache']] : [];
if (!empty($item['marked'])) {
$item['categories'][] = 'user/-/state/com.google/starred';
}
if (!empty($item['published'])) {
$item['categories'][] = 'user/-/state/com.google/broadcast';
}
if (!empty($item['label_cache'])) {
if (is_string($item['label_cache'] ?? null)) {
$labels_cache = json_decode($item['label_cache'], true);
if (is_array($labels_cache)) {
foreach ($labels_cache as $label_cache) {
if (!empty($label_cache[1]) && is_string($label_cache[1])) {
if (is_array($label_cache) && !empty($label_cache[1]) && is_string($label_cache[1])) {
$item['categories'][] = 'user/-/label/' . trim($label_cache[1]);
}
}
}
}
$item['alternate'][0]['href'] = $item['link'] ?? '';
$item['alternate'] = [['href' => $item['link'] ?? '']];
$item['origin'] = [
'title' => $item['feed_title'] ?? '',
'feedUrl' => $item['feed_url'] ?? '',
Expand Down Expand Up @@ -290,6 +294,9 @@ private function importJson(string $article_file, bool $starred = false): bool {
return false;
}
$items = $article_object['items'] ?? $article_object;
if (!is_array($items)) {
$items = [];
}

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

Expand All @@ -302,29 +309,32 @@ private function importJson(string $article_file, bool $starred = false): bool {

// First, we check feeds of articles are in DB (and add them if needed).
foreach ($items as &$item) {
if (!isset($item['guid']) && isset($item['id'])) {
if (!is_array($item)) {
continue;
}
if (!is_string($item['guid'] ?? null) && is_string($item['id'] ?? null)) {
$item['guid'] = $item['id'];
}
if (empty($item['guid'])) {
if (!is_string($item['guid'] ?? null)) {
continue;
}
if (empty($item['origin'])) {
if (!is_array($item['origin'] ?? null)) {
$item['origin'] = [];
}
if (empty($item['origin']['title']) || trim($item['origin']['title']) === '') {
if (!is_string($item['origin']['title'] ?? null) || trim($item['origin']['title']) === '') {
$item['origin']['title'] = 'Import';
}
if (!empty($item['origin']['feedUrl'])) {
if (is_string($item['origin']['feedUrl'] ?? null)) {
$feedUrl = $item['origin']['feedUrl'];
} elseif (!empty($item['origin']['streamId']) && str_starts_with($item['origin']['streamId'], 'feed/')) {
} elseif (is_string($item['origin']['streamId'] ?? null) && str_starts_with($item['origin']['streamId'], 'feed/')) {
$feedUrl = substr($item['origin']['streamId'], 5); //Google Reader
$item['origin']['feedUrl'] = $feedUrl;
} elseif (!empty($item['origin']['htmlUrl'])) {
} elseif (is_string($item['origin']['htmlUrl'] ?? null)) {
$feedUrl = $item['origin']['htmlUrl'];
} else {
$feedUrl = 'http://import.localhost/import.xml';
$item['origin']['feedUrl'] = $feedUrl;
$item['origin']['disable'] = true;
$item['origin']['disable'] = 'true';
}
$feed = new FreshRSS_Feed($feedUrl);
$feed = $this->feedDAO->searchByUrl($feed->url());
Expand All @@ -335,7 +345,8 @@ private function importJson(string $article_file, bool $starred = false): bool {
// Oops, no more place!
Minz_Log::warning(_t('feedback.sub.feed.over_max', $limits['max_feeds']));
} else {
$feed = $this->addFeedJson($item['origin']);
$origin = array_filter($item['origin'], fn($value, $key): bool => is_string($key) && is_string($value), ARRAY_FILTER_USE_BOTH);
$feed = $this->addFeedJson($origin);
}

if ($feed === null) {
Expand Down Expand Up @@ -375,19 +386,24 @@ private function importJson(string $article_file, bool $starred = false): bool {
$newGuids = [];
$this->entryDAO->beginTransaction();
foreach ($items as &$item) {
if (empty($item['guid']) || empty($article_to_feed[$item['guid']])) {
if (!is_array($item) || empty($item['guid']) || !is_string($item['guid']) || empty($article_to_feed[$item['guid']])) {
// Related feed does not exist for this entry, do nothing.
continue;
}

$feed_id = $article_to_feed[$item['guid']];
$author = $item['author'] ?? '';
$author = is_string($item['author'] ?? null) ? $item['author'] : '';
$is_starred = null; // null is used to preserve the current state if that item exists and is already starred
$is_read = null;
$tags = empty($item['categories']) ? [] : $item['categories'];
$tags = is_array($item['categories'] ?? null) ? $item['categories'] : [];
$labels = [];
for ($i = count($tags) - 1; $i >= 0; $i--) {
$tag = trim($tags[$i]);
$tag = $tags[$i];
if (!is_string($tag)) {
unset($tags[$i]);
continue;
}
$tag = trim($tag);
if (preg_match('%^user/[A-Za-z0-9_-]+/%', $tag)) {
if (preg_match('%^user/[A-Za-z0-9_-]+/state/com.google/starred$%', $tag)) {
$is_starred = true;
Expand All @@ -401,6 +417,7 @@ private function importJson(string $article_file, bool $starred = false): bool {
unset($tags[$i]);
}
}
$tags = array_values(array_filter($tags, 'is_string'));
if ($starred && !$is_starred) {
//If the article has no label, mark it as starred (old format)
$is_starred = empty($labels);
Expand All @@ -409,41 +426,38 @@ private function importJson(string $article_file, bool $starred = false): bool {
$is_read = $mark_as_read;
}

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

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

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

if (!empty($item['published'])) {
$published = '' . $item['published'];
} elseif (!empty($item['timestampUsec'])) {
$published = substr('' . $item['timestampUsec'], 0, -6);
} elseif (!empty($item['updated'])) {
$published = '' . $item['updated'];
if (is_int($item['published'] ?? null) || is_string($item['published'] ?? null)) {
$published = (string)$item['published'];
} elseif (is_int($item['timestampUsec'] ?? null) || is_string($item['timestampUsec'] ?? null)) {
$published = substr((string)$item['timestampUsec'], 0, -6);
} elseif (is_int($item['updated'] ?? null) || is_string($item['updated'] ?? null)) {
$published = (string)$item['updated'];
} else {
$published = '0';
}
if (!ctype_digit($published)) {
$published = '' . strtotime($published);
$published = (string)strtotime($published);
}
if (strlen($published) > 10) { // Milliseconds, e.g. Feedly
$published = substr($published, 0, -3);
Expand Down
8 changes: 5 additions & 3 deletions app/Controllers/indexController.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ public function rssAction(): void {

$this->view->html_url = Minz_Url::display('', 'html', true);
$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();

$queryString = $_SERVER['QUERY_STRING'] ?? '';
$this->view->rss_url = htmlspecialchars(
PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']), ENT_COMPAT, 'UTF-8');
PUBLIC_TO_INDEX_PATH . '/' . ($queryString === '' || !is_string($queryString) ? '' : '?' . $queryString), ENT_COMPAT, 'UTF-8');

// No layout for RSS output.
$this->view->_layout(null);
Expand Down Expand Up @@ -216,7 +218,7 @@ public function opmlAction(): void {
Minz_Error::error(404);
return;
}
$this->view->categories = [ $cat->id() => $cat ];
$this->view->categories = [ $cat ];
break;
case 'f':
// We most likely already have the feed object in cache
Expand All @@ -229,7 +231,7 @@ public function opmlAction(): void {
return;
}
}
$this->view->feeds = [ $feed->id() => $feed ];
$this->view->feeds = [ $feed ];
break;
case 's':
case 't':
Expand Down
Loading