Ver código fonte

Pass PHPStan level 9 (#6544)

* More PHPStan

* More, passing

* 4 more files

* Update to PHPStan 1.11.4
Needed for fixed bug: Consider numeric-string types after string concat
https://github.com/phpstan/phpstan/releases/tag/1.11.4

* Pass PHPStan level 9
Start tracking booleansInConditions

* Fix mark as read

* Fix doctype

* ctype_digit
Alexandre Alapetite 1 ano atrás
pai
commit
5b28a35003

+ 2 - 2
app/Controllers/apiController.php

@@ -21,7 +21,7 @@ class FreshRSS_api_Controller extends FreshRSS_ActionController {
 		FreshRSS_Context::userConf()->apiPasswordHash = $apiPasswordHash;
 
 		$feverKey = FreshRSS_fever_Util::updateKey($username, $apiPasswordPlain);
-		if (!$feverKey) {
+		if ($feverKey == false) {
 			return _t('feedback.api.password.failed');
 		}
 
@@ -56,7 +56,7 @@ class FreshRSS_api_Controller extends FreshRSS_ActionController {
 		}
 
 		$error = self::updatePassword($apiPasswordPlain);
-		if ($error) {
+		if (is_string($error)) {
 			Minz_Request::bad($error, $return_url);
 		} else {
 			Minz_Request::good(_t('feedback.api.password.updated'), $return_url);

+ 1 - 1
app/Controllers/authController.php

@@ -191,7 +191,7 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 			$password = Minz_Request::paramString('p');
 			Minz_Request::_param('p');
 
-			if (!$username) {
+			if ($username === '') {
 				return;
 			}
 

+ 10 - 9
app/Controllers/configureController.php

@@ -204,6 +204,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 				$default = Minz_Configuration::load(FRESHRSS_PATH . '/config-user.default.php');
 				$shortcuts = $default['shortcuts'];
 			}
+			/** @var array<string,string> $shortcuts */
 			FreshRSS_Context::userConf()->shortcuts = array_map('trim', $shortcuts);
 			FreshRSS_Context::userConf()->save();
 			invalidateHttpCache();
@@ -384,27 +385,27 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 				$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($params['state']);
 			}
 			if (empty($params['token']) || !is_string($params['token'])) {
 				$queryParams['token'] = FreshRSS_UserQuery::generateToken($name);
 			} else {
 				$queryParams['token'] = $params['token'];
 			}
-			if (!empty($params['shareRss']) && ctype_digit($params['shareRss'])) {
-				$queryParams['shareRss'] = (bool)$params['shareRss'];
-			}
-			if (!empty($params['shareOpml']) && ctype_digit($params['shareOpml'])) {
-				$queryParams['shareOpml'] = (bool)$params['shareOpml'];
-			}
+			$queryParams['url'] = Minz_Url::display(['params' => $queryParams]);
+			$queryParams['name'] = $name;
 			if (!empty($params['description']) && is_string($params['description'])) {
 				$queryParams['description'] = htmlspecialchars_decode($params['description'], ENT_QUOTES);
 			}
 			if (!empty($params['imageUrl']) && is_string($params['imageUrl'])) {
 				$queryParams['imageUrl'] = $params['imageUrl'];
 			}
-			$queryParams['url'] = Minz_Url::display(['params' => $queryParams]);
-			$queryParams['name'] = $name;
+			if (!empty($params['shareOpml']) && ctype_digit($params['shareOpml'])) {
+				$queryParams['shareOpml'] = (bool)$params['shareOpml'];
+			}
+			if (!empty($params['shareRss']) && ctype_digit($params['shareRss'])) {
+				$queryParams['shareRss'] = (bool)$params['shareRss'];
+			}
 
 			$queries = FreshRSS_Context::userConf()->queries;
 			$queries[$id] = (new FreshRSS_UserQuery($queryParams, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();

+ 17 - 6
app/Controllers/entryController.php

@@ -44,10 +44,12 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 	 *   - is_read (default: true)
 	 */
 	public function readAction(): void {
-		$id = Minz_Request::param('id');
 		$get = Minz_Request::paramString('get');
 		$next_get = Minz_Request::paramString('nextGet') ?: $get;
 		$id_max = Minz_Request::paramString('idMax') ?: '0';
+		if (!ctype_digit($id_max)) {
+			$id_max = '0';
+		}
 		$is_read = Minz_Request::paramTernary('is_read') ?? true;
 		FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
 
@@ -64,14 +66,14 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 		$this->view->tagsForEntries = [];
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
-		if ($id == false) {
-			// id is false? It MUST be a POST request!
+		if (!Minz_Request::hasParam('id')) {
+			// No id, then it MUST be a POST request
 			if (!Minz_Request::isPost()) {
 				Minz_Request::bad(_t('feedback.access.not_found'), ['c' => 'index', 'a' => 'index']);
 				return;
 			}
 
-			if (!$get) {
+			if ($get === '') {
 				// No get? Mark all entries as read (from $id_max)
 				$entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Feed::PRIORITY_IMPORTANT, null, 0, $is_read);
 			} else {
@@ -111,7 +113,16 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 				}
 			}
 		} else {
-			$ids = is_array($id) ? $id : [$id];
+			/** @var array<numeric-string> $idArray */
+			$idArray = Minz_Request::paramArray('id');
+			$idString = Minz_Request::paramString('id');
+			if (count($idArray) > 0) {
+				$ids = $idArray;
+			} elseif (ctype_digit($idString)) {
+				$ids = [$idString];
+			} else {
+				$ids = [];
+			}
 			$entryDAO->markRead($ids, $is_read);
 			$tagDAO = FreshRSS_Factory::createTagDao();
 			$tagsForEntries = $tagDAO->getTagsForEntries($ids) ?: [];
@@ -145,7 +156,7 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 	public function bookmarkAction(): void {
 		$id = Minz_Request::paramString('id');
 		$is_favourite = Minz_Request::paramTernary('is_favorite') ?? true;
-		if ($id != '') {
+		if ($id != '' && ctype_digit($id)) {
 			$entryDAO = FreshRSS_Factory::createEntryDao();
 			$entryDAO->markFavorite($id, $is_favourite);
 		}

+ 1 - 2
app/Controllers/feedController.php

@@ -439,9 +439,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 		$nb_new_articles = 0;
 
 		foreach ($feeds as $feed) {
-			/** @var FreshRSS_Feed|null $feed */
 			$feed = Minz_ExtensionManager::callHook('feed_before_actualize', $feed);
-			if (null === $feed) {
+			if (!($feed instanceof FreshRSS_Feed)) {
 				continue;
 			}
 

+ 1 - 1
app/Controllers/importExportController.php

@@ -563,7 +563,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 
 			// Call the extension hook
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if ($feed != null) {
+			if ($feed instanceof FreshRSS_Feed) {
 				// addFeedObject checks if feed is already in DB so nothing else to
 				// check here.
 				$id = $this->feedDAO->addFeedObject($feed);

+ 3 - 3
app/Models/DatabaseDAO.php

@@ -81,7 +81,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		return count(array_keys($tables, true, true)) === count($tables);
 	}
 
-	/** @return array<array<string,string|int|bool|null>> */
+	/** @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);
@@ -160,7 +160,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param array<string,string|int|bool|null> $dao
-	 * @return array{'name':string,'type':string,'notnull':bool,'default':mixed}
+	 * @return array{name:string,type:string,notnull:bool,default:mixed}
 	 */
 	public function daoToSchema(array $dao): array {
 		return [
@@ -173,7 +173,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param array<array<string,string|int|bool|null>> $listDAO
-	 * @return array<array<string,string|int|bool|null>>
+	 * @return array<array{name:string,type:string,notnull:bool,default:mixed}>
 	 */
 	public function listDaoToSchema(array $listDAO): array {
 		$list = [];

+ 1 - 1
app/Models/DatabaseDAOPGSQL.php

@@ -34,7 +34,7 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
 		return count(array_keys($tables, true, true)) === count($tables);
 	}
 
-	/** @return array<array<string,string|int|bool|null>> */
+	/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
 	#[\Override]
 	public function getSchema(string $table): array {
 		$sql = <<<'SQL'

+ 1 - 1
app/Models/DatabaseDAOSQLite.php

@@ -30,7 +30,7 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 		return count(array_keys($tables, true, true)) == count($tables);
 	}
 
-	/** @return array<array<string,string|int|bool|null>> */
+	/** @return array<array{name:string,type:string,notnull:bool,default:mixed}> */
 	#[\Override]
 	public function getSchema(string $table): array {
 		$sql = 'PRAGMA table_info(' . $table . ')';

+ 15 - 7
app/Models/Entry.php

@@ -10,6 +10,7 @@ class FreshRSS_Entry extends Minz_Model {
 	public const STATE_FAVORITE = 4;
 	public const STATE_NOT_FAVORITE = 8;
 
+	/** @var numeric-string */
 	private string $id = '0';
 	private string $guid;
 	private string $title;
@@ -110,6 +111,7 @@ class FreshRSS_Entry extends Minz_Model {
 		}
 	}
 
+	/** @return numeric-string */
 	public function id(): string {
 		return $this->id;
 	}
@@ -195,8 +197,8 @@ class FreshRSS_Entry extends Minz_Model {
 		$thumbnailAttribute = $this->attributeArray('thumbnail') ?? [];
 		if (!empty($thumbnailAttribute['url'])) {
 			$elink = $thumbnailAttribute['url'];
-			if ($allowDuplicateEnclosures || !self::containsLink($content, $elink)) {
-			$content .= <<<HTML
+			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="" />
@@ -216,7 +218,7 @@ HTML;
 				continue;
 			}
 			$elink = $enclosure['url'] ?? '';
-			if ($elink == '') {
+			if ($elink == '' || !is_string($elink)) {
 				continue;
 			}
 			if (!$allowDuplicateEnclosures && self::containsLink($content, $elink)) {
@@ -281,6 +283,7 @@ HTML;
 		$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 {
@@ -296,8 +299,10 @@ HTML;
 				// Legacy code for database entries < FreshRSS 1.20.1
 				$enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]');
 				if (!empty($enclosures)) {
-					/** @var DOMElement $enclosure */
 					foreach ($enclosures as $enclosure) {
+						if (!($enclosure instanceof DOMElement)) {
+							continue;
+						}
 						$result = [
 							'url' => $enclosure->getAttribute('src'),
 							'type' => $enclosure->getAttribute('data-type'),
@@ -318,8 +323,10 @@ HTML;
 			if ($searchBodyImages && $xpath !== null) {
 				$images = $xpath->query('//img');
 				if (!empty($images)) {
-					/** @var DOMElement $img */
 					foreach ($images as $img) {
+						if (!($img instanceof DOMElement)) {
+							continue;
+						}
 						$src = $img->getAttribute('src');
 						if ($src == null) {
 							$src = $img->getAttribute('data-src');
@@ -346,6 +353,7 @@ HTML;
 		$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) {
@@ -467,7 +475,7 @@ HTML;
 		return $this->hash;
 	}
 
-	/** @param int|string $value String is for compatibility with 32-bit platforms */
+	/** @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;
@@ -882,7 +890,7 @@ HTML;
 
 	/**
 	 * Integer format conversion for Google Reader API format
-	 * @param string|int $dec Decimal number
+	 * @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 {

+ 7 - 7
app/Models/EntryDAO.php

@@ -321,7 +321,7 @@ SQL;
 	 * @todo simplify the query by removing the str_repeat. I am pretty sure
 	 * there is an other way to do that.
 	 *
-	 * @param string|array<string> $ids
+	 * @param numeric-string|array<numeric-string> $ids
 	 * @return int|false
 	 */
 	public function markFavorite($ids, bool $is_favorite = true) {
@@ -399,7 +399,7 @@ SQL;
 	 * Toggle the read marker on one or more article.
 	 * Then the cache is updated.
 	 *
-	 * @param string|array<string> $ids
+	 * @param numeric-string|array<numeric-string> $ids
 	 * @param bool $is_read
 	 * @return int|false affected rows
 	 */
@@ -465,7 +465,7 @@ SQL;
 	 *
 	 * If $idMax equals 0, a deprecated debug message is logged
 	 *
-	 * @param string $idMax fail safe article ID
+	 * @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,
@@ -517,7 +517,7 @@ SQL;
 	 * If $idMax equals 0, a deprecated debug message is logged
 	 *
 	 * @param int $id category ID
-	 * @param string $idMax fail safe article 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) {
@@ -558,7 +558,7 @@ SQL;
 	 * If $idMax equals 0, a deprecated debug message is logged
 	 *
 	 * @param int $id_feed feed ID
-	 * @param string $idMax fail safe article 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) {
@@ -612,7 +612,7 @@ SQL;
 	/**
 	 * 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
+	 * @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,
@@ -1206,7 +1206,7 @@ SQL;
 	}
 
 	/**
-	 * @param array<string> $ids
+	 * @param array<numeric-string> $ids
 	 * @param 'ASC'|'DESC' $order
 	 * @return Traversable<FreshRSS_Entry>
 	 */

+ 17 - 13
app/Models/Feed.php

@@ -658,7 +658,7 @@ class FreshRSS_Feed extends Minz_Model {
 
 		//check if the content is actual JSON
 		$jf = json_decode($json, true);
-		if (json_last_error() !== JSON_ERROR_NONE) {
+		if (json_last_error() !== JSON_ERROR_NONE || !is_array($jf)) {
 			return null;
 		}
 
@@ -734,9 +734,14 @@ class FreshRSS_Feed extends Minz_Model {
 			}
 
 			$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(@$xpath->evaluate('normalize-space(' . $xPathFeedTitle . ')'), ENT_COMPAT, 'UTF-8');
-			$view->rss_base = htmlspecialchars(trim($xpath->evaluate('normalize-space(//base/@href)')), ENT_COMPAT, 'UTF-8');
+				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;
@@ -744,7 +749,7 @@ class FreshRSS_Feed extends Minz_Model {
 
 			foreach ($nodes as $node) {
 				$item = [];
-				$item['title'] = $xPathItemTitle == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTitle . ')', $node);
+				$item['title'] = $xPathItemTitle == '' ? '' : $xpathEvaluateString($xPathItemTitle, $node);
 
 				$item['content'] = '';
 				if ($xPathItemContent != '') {
@@ -756,36 +761,35 @@ class FreshRSS_Feed extends Minz_Model {
 							$content .= $doc->saveHTML($child) . "\n";
 						}
 						$item['content'] = $content;
-					} else {
+					} elseif (is_string($result) || is_int($result) || is_bool($result)) {
 						// Typed expression, save as-is
 						$item['content'] = (string)$result;
 					}
 				}
 
-				$item['link'] = $xPathItemUri == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemUri . ')', $node);
-				$item['author'] = $xPathItemAuthor == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemAuthor . ')', $node);
-				$item['timestamp'] = $xPathItemTimestamp == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTimestamp . ')', $node);
+				$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'] ?? '');
+					$dateTime = DateTime::createFromFormat($xPathItemTimeFormat, $item['timestamp']);
 					if ($dateTime != false) {
 						$item['timestamp'] = $dateTime->format(DateTime::ATOM);
 					}
 				}
-				$item['thumbnail'] = $xPathItemThumbnail == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemThumbnail . ')', $node);
+				$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'] = [];
-						/** @var DOMNode $itemCategory */
 						foreach ($itemCategories as $itemCategory) {
 							$item['tags'][] = $itemCategory->textContent;
 						}
 					}
 				}
 				if ($xPathItemUid != '') {
-					$item['guid'] = @$xpath->evaluate('normalize-space(' . $xPathItemUid . ')', $node);
+					$item['guid'] = $xpathEvaluateString($xPathItemUid, $node);
 				}
 				if (empty($item['guid'])) {
 					$item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']);
@@ -958,7 +962,7 @@ class FreshRSS_Feed extends Minz_Model {
 		$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) {
+		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);

+ 4 - 4
app/Models/ReadingMode.php

@@ -9,13 +9,13 @@ class FreshRSS_ReadingMode {
 	protected string $id;
 	protected string $name;
 	protected string $title;
-	/** @var array{'c':string,'a':string,'params':array<string,mixed>} */
+	/** @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
+	 * @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;
@@ -47,12 +47,12 @@ class FreshRSS_ReadingMode {
 		return $this;
 	}
 
-	/** @return array{'c':string,'a':string,'params':array<string,mixed>} */
+	/** @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 */
+	/** @param array{c:string,a:string,params:array<string,mixed>} $urlParams */
 	public function setUrlParams(array $urlParams): FreshRSS_ReadingMode {
 		$this->urlParams = $urlParams;
 		return $this;

+ 1 - 1
app/Models/UserConfiguration.php

@@ -69,7 +69,7 @@ declare(strict_types=1);
  * @property int $dynamic_opml_ttl_default
  * @property-read bool $unsafe_autologin_enabled
  * @property string $view_mode
- * @property array<string,mixed> $volatile
+ * @property array<string,bool|int|string> $volatile
  * @property array<string,array<string,mixed>> $extensions
  */
 final class FreshRSS_UserConfiguration extends Minz_Configuration {

+ 9 - 3
app/Models/UserQuery.php

@@ -41,7 +41,8 @@ class FreshRSS_UserQuery {
 	}
 
 	/**
-	 * @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool} $query
+	 * @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
 	 */
@@ -61,8 +62,13 @@ class FreshRSS_UserQuery {
 		}
 		if (empty($query['url'])) {
 			if (!empty($query)) {
-				unset($query['name']);
-				$this->url = Minz_Url::display(['params' => $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'];

+ 9 - 9
app/Services/ImportService.php

@@ -78,13 +78,13 @@ class FreshRSS_Import_Service {
 			$category_element = $categories_elements[$category_name] ?? null;
 
 			$category = null;
-			if ($forced_category) {
+			if ($forced_category !== null) {
 				// If the category is forced, ignore the actual category name
 				$category = $forced_category;
 			} elseif (isset($categories_by_names[$category_name])) {
 				// If the category already exists, get it from $categories_by_names
 				$category = $categories_by_names[$category_name];
-			} elseif ($category_element) {
+			} elseif (is_array($category_element)) {
 				// Otherwise, create the category (if possible)
 				$limit_reached = $nb_categories >= $limits['max_categories'];
 				$can_create_category = FreshRSS_Context::$isCli || !$limit_reached;
@@ -362,11 +362,11 @@ class FreshRSS_Import_Service {
 	 * This method is applied to a list of outlines. It merges the different
 	 * list of feeds from several outlines into one array.
 	 *
-	 * @param array<mixed> $outlines
+	 * @param array<array<mixed>> $outlines
 	 *     The outlines from which to extract the outlines.
 	 * @param string $parent_category_name
 	 *     The name of the parent category of the current outlines.
-	 * @return array{0:array<mixed>,1:array<mixed>}
+	 * @return array{0:array<string,array<string,string>>,1:array<string,array<array<string,string>>>}
 	 */
 	private function loadFromOutlines(array $outlines, string $parent_category_name): array {
 		$categories_elements = [];
@@ -410,13 +410,13 @@ class FreshRSS_Import_Service {
 	 * @param string $parent_category_name
 	 *     The name of the parent category of the current outline.
 	 *
-	 * @return array{0:array<string,mixed>,1:array<string,mixed>}
+	 * @return array{0:array<string,array<string,string>>,1:array<array<string,array<string,string>>>}
 	 */
-	private function loadFromOutline($outline, $parent_category_name): array {
+	private function loadFromOutline(array $outline, string $parent_category_name): array {
 		$categories_elements = [];
 		$categories_to_feeds = [];
 
-		if ($parent_category_name === '' && isset($outline['category'])) {
+		if ($parent_category_name === '' && isset($outline['category']) && is_array($outline['category'])) {
 			// The outline has no parent category, but its OPML category
 			// attribute is set, so we use it as the category name.
 			// lib_opml parses this attribute as an array of strings, so we
@@ -429,9 +429,9 @@ class FreshRSS_Import_Service {
 
 		if (isset($outline['@outlines'])) {
 			// The outline has children, it’s probably a category
-			if (!empty($outline['text'])) {
+			if (!empty($outline['text']) && is_string($outline['text'])) {
 				$category_name = $outline['text'];
-			} elseif (!empty($outline['title'])) {
+			} elseif (!empty($outline['title']) && is_string($outline['title'])) {
 				$category_name = $outline['title'];
 			} else {
 				$category_name = $parent_category_name;

+ 4 - 4
app/layout/layout.phtml

@@ -41,8 +41,8 @@
 	if ($this->rss_title != '') {
 		$url_rss = $url_base;
 		$url_rss['a'] = 'rss';
-		$url_rss['params']['user'] = Minz_User::name();
-		$url_rss['params']['token'] = FreshRSS_Context::userConf()->token ?: null;
+		$url_rss['params']['user'] = Minz_User::name() ?? '';
+		$url_rss['params']['token'] = FreshRSS_Context::userConf()->token;
 		unset($url_rss['params']['rid']);
 		if (FreshRSS_Context::userConf()->since_hours_posts_per_rss) {
 			$url_rss['params']['hours'] = FreshRSS_Context::userConf()->since_hours_posts_per_rss;
@@ -52,8 +52,8 @@
 <?php } if (FreshRSS_Context::isAll() || FreshRSS_Context::isCategory() || FreshRSS_Context::isFeed()) {
 		$opml_rss = $url_base;
 		$opml_rss['a'] = 'opml';
-		$opml_rss['params']['user'] = Minz_User::name();
-		$opml_rss['params']['token'] = FreshRSS_Context::userConf()->token ?: null;
+		$opml_rss['params']['user'] = Minz_User::name() ?? '';
+		$opml_rss['params']['token'] = FreshRSS_Context::userConf()->token;
 		unset($opml_rss['params']['rid']);
 ?>
 		<link rel="outline" type="text/x-opml" title="OPML" href="<?= Minz_Url::display($opml_rss) ?>" />

+ 12 - 14
app/views/helpers/feed/update.phtml

@@ -421,6 +421,7 @@
 
 		<fieldset id="html_xpath">
 			<?php
+				/** @var array<string> $xpath */
 				$xpath = Minz_Helper::htmlspecialchars_utf8($this->feed->attributeArray('xpath') ?? []);
 			?>
 			<p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.help') ?></p>
@@ -516,6 +517,7 @@
 
 		<fieldset id="json_dotnotation">
 			<?php
+				/** @var array<string,string> $jsonSettings */
 				$jsonSettings = Minz_Helper::htmlspecialchars_utf8($this->feed->attributeArray('json_dotnotation') ?? []);
 			?>
 			<p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.json_dotnotation.help') ?></p>
@@ -634,17 +636,19 @@
 			</div>
 
 			<div class="form-group">
+				<?php
+					/** @var array<int,int|string> $curlParams */
+					$curlParams = $this->feed->attributeArray('curl_params') ?? [];
+				?>
 				<label class="group-name" for="curl_params_cookie"><?= _t('sub.feed.css_cookie') ?></label>
 				<div class="group-controls">
 					<input type="text" name="curl_params_cookie" id="curl_params_cookie" class="w100" value="<?=
-						$this->feed->attributeArray('curl_params') !== null && !empty($this->feed->attributeArray('curl_params')[CURLOPT_COOKIE]) ?
-							$this->feed->attributeArray('curl_params')[CURLOPT_COOKIE] : ''
+						!empty($curlParams[CURLOPT_COOKIE]) ? $curlParams[CURLOPT_COOKIE] : ''
 					?>" placeholder="<?= _t('gen.short.blank_to_disable') ?>" />
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.css_cookie_help') ?></p>
 					<label for="curl_params_cookiefile">
 						<input type="checkbox" name="curl_params_cookiefile" id="curl_params_cookiefile" value="1"<?=
-							$this->feed->attributeArray('curl_params') !== null && isset($this->feed->attributeArray('curl_params')[CURLOPT_COOKIEFILE]) ?
-								' checked="checked"' : ''
+							isset($curlParams[CURLOPT_COOKIEFILE]) ? ' checked="checked"' : ''
 						?> />
 						<?= _t('sub.feed.accept_cookies') ?>
 					</label>
@@ -656,8 +660,7 @@
 				<label class="group-name" for="curl_params_redirects"><?= _t('sub.feed.max_http_redir') ?></label>
 				<div class="group-controls">
 					<input type="number" name="curl_params_redirects" id="curl_params_redirects" class="w50" min="-1" value="<?=
-						$this->feed->attributeArray('curl_params') !== null && !empty($this->feed->attributeArray('curl_params')[CURLOPT_MAXREDIRS]) ?
-							$this->feed->attributeArray('curl_params')[CURLOPT_MAXREDIRS] : ''
+						!empty($curlParams[CURLOPT_MAXREDIRS]) ? $curlParams[CURLOPT_MAXREDIRS] : ''
 					?>" placeholder="<?= _t('gen.short.blank_to_disable') ?>" />
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.max_http_redir_help') ?></p>
 				</div>
@@ -678,8 +681,7 @@
 				<label class="group-name" for="curl_params_useragent"><?= _t('sub.feed.useragent') ?></label>
 				<div class="group-controls">
 					<input type="text" name="curl_params_useragent" id="curl_params_useragent" class="w100" value="<?=
-						$this->feed->attributeArray('curl_params') !== null && !empty($this->feed->attributeArray('curl_params')[CURLOPT_USERAGENT]) ?
-							$this->feed->attributeArray('curl_params')[CURLOPT_USERAGENT] : ''
+						!empty($curlParams[CURLOPT_USERAGENT]) ? $curlParams[CURLOPT_USERAGENT] : ''
 					?>" placeholder="<?= _t('gen.short.blank_to_disable') ?>" />
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.useragent_help') ?></p>
 				</div>
@@ -689,18 +691,14 @@
 				<label class="group-name" for="proxy_type"><?= _t('sub.feed.proxy') ?></label>
 				<div class="group-controls">
 					<select name="proxy_type" id="proxy_type"><?php
-						$type = '';
-						if ($this->feed->attributeArray('curl_params') !== null && isset($this->feed->attributeArray('curl_params')[CURLOPT_PROXYTYPE])) {
-							$type = $this->feed->attributeArray('curl_params')[CURLOPT_PROXYTYPE];
-						}
+						$type = $curlParams[CURLOPT_PROXYTYPE] ?? '';
 						foreach(['' => '', 3 => 'NONE', 0 => 'HTTP', 2 => 'HTTPS', 4 => 'SOCKS4', 6 => 'SOCKS4A', 5 => 'SOCKS5', 7 => 'SOCKS5H'] as $k => $v) {
 							echo '<option value="' . $k . ($type === $k ? '" selected="selected' : '' ) . '">' . $v . '</option>';
 						}
 					?>
 					</select>
 					<input type="text" name="curl_params" id="curl_params" value="<?=
-						$this->feed->attributeArray('curl_params') !== null && !empty($this->feed->attributeArray('curl_params')[CURLOPT_PROXY]) ?
-							$this->feed->attributeArray('curl_params')[CURLOPT_PROXY] : ''
+						!empty($curlParams[CURLOPT_PROXY]) ? $curlParams[CURLOPT_PROXY] : ''
 					?>" placeholder="<?= _t('gen.short.blank_to_disable') ?>" />
 					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.proxy_help') ?></p>
 				</div>

+ 2 - 2
app/views/helpers/index/article.phtml

@@ -10,11 +10,11 @@
 		<?php
 			$favoriteUrl = ['c' => 'entry', 'a' => 'bookmark', 'params' => ['id' => $entry->id()]];
 			if ($entry->isFavorite()) {
-				$favoriteUrl['params']['is_favorite'] = 0;
+				$favoriteUrl['params']['is_favorite'] = '0';
 			}
 			$readUrl = ['c' => 'entry', 'a' => 'read', 'params' => ['id' => $entry->id()]];
 			if ($entry->isRead()) {
-				$readUrl['params']['is_read'] = 0;
+				$readUrl['params']['is_read'] = '0';
 			}
 		?>
 		<div class="article-header-topline">

+ 4 - 4
app/views/helpers/index/normal/entry_bottom.phtml

@@ -12,9 +12,9 @@
 	if (FreshRSS_Auth::hasAccess()) {
 		if ($bottomline_read) {
 			?><li class="item manage"><?php
-				$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $this->entry->id()));
+				$arUrl = ['c' => 'entry', 'a' => 'read', 'params' => ['id' => $this->entry->id()]];
 				if ($this->entry->isRead()) {
-					$arUrl['params']['is_read'] = 0;
+					$arUrl['params']['is_read'] = '0';
 				}
 				?><a class="item-element read" href="<?= Minz_Url::display($arUrl) ?>" title="<?= _t('conf.shortcut.mark_read') ?>"><?php
 					echo _i($this->entry->isRead() ? 'read' : 'unread'); ?></a><?php
@@ -22,9 +22,9 @@
 		}
 		if ($bottomline_favorite) {
 			?><li class="item manage"><?php
-				$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $this->entry->id()));
+				$arUrl = ['c' => 'entry', 'a' => 'bookmark', 'params' => ['id' => $this->entry->id()]];
 				if ($this->entry->isFavorite()) {
-					$arUrl['params']['is_favorite'] = 0;
+					$arUrl['params']['is_favorite'] = '0';
 				}
 				?><a class="item-element bookmark" href="<?= Minz_Url::display($arUrl) ?>" title="<?= _t('conf.shortcut.mark_favorite') ?>"><?php
 					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php

+ 4 - 4
app/views/helpers/index/normal/entry_header.phtml

@@ -14,9 +14,9 @@
 	if (FreshRSS_Auth::hasAccess()) {
 		if ($topline_read) {
 			?><li class="item manage"><?php
-				$arUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $this->entry->id()));
+				$arUrl = ['c' => 'entry', 'a' => 'read', 'params' => ['id' => $this->entry->id()]];
 				if ($this->entry->isRead()) {
-					$arUrl['params']['is_read'] = 0;
+					$arUrl['params']['is_read'] = '0';
 				}
 				?><a class="item-element read" href="<?= Minz_Url::display($arUrl) ?>" title="<?= _t('conf.shortcut.mark_read') ?>"><?php
 					echo _i($this->entry->isRead() ? 'read' : 'unread'); ?></a><?php
@@ -24,9 +24,9 @@
 		}
 		if ($topline_favorite) {
 			?><li class="item manage"><?php
-				$arUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $this->entry->id()));
+				$arUrl = ['c' => 'entry', 'a' => 'bookmark', 'params' => ['id' => $this->entry->id()]];
 				if ($this->entry->isFavorite()) {
-					$arUrl['params']['is_favorite'] = 0;
+					$arUrl['params']['is_favorite'] = '0';
 				}
 				?><a class="item-element bookmark" href="<?= Minz_Url::display($arUrl) ?>" title="<?= _t('conf.shortcut.mark_favorite') ?>"><?php
 					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php

+ 6 - 6
app/views/helpers/stream-footer.phtml

@@ -3,20 +3,20 @@
 	/** @var FreshRSS_View $this */
 	$url_next = Minz_Request::currentRequest();
 	$url_next['params']['next'] = FreshRSS_Context::$next_id;
-	$url_next['params']['state'] = FreshRSS_Context::$state;
-	$url_next['params']['ajax'] = 1;
+	$url_next['params']['state'] = (string)FreshRSS_Context::$state;
+	$url_next['params']['ajax'] = '1';
 
-	$url_mark_read = array(
+	$url_mark_read = [
 		'c' => 'entry',
 		'a' => 'read',
-		'params' => array(
+		'params' => [
 			'get' => FreshRSS_Context::currentGet(),
 			'nextGet' => FreshRSS_Context::$next_get,
 			'idMax' => FreshRSS_Context::$id_max,
 			'search' => htmlspecialchars_decode(FreshRSS_Context::$search->getRawInput(), ENT_QUOTES),
 			'state' => FreshRSS_Context::$state,
-		)
-	);
+		],
+	];
 
 	$hasAccess = FreshRSS_Auth::hasAccess();
 	if ($hasAccess) {

+ 7 - 7
app/views/javascript/actualize.phtml

@@ -5,21 +5,21 @@ declare(strict_types=1);
 $categories = [];
 foreach ($this->categories as $category) {
 	$categories[] = [
-		'url' => Minz_Url::display(array('c' => 'category', 'a' => 'refreshOpml', 'params' => array('id' => $category->id(), 'ajax' => '1')), 'php'),
+		'url' => Minz_Url::display(['c' => 'category', 'a' => 'refreshOpml', 'params' => ['id' => $category->id(), 'ajax' => '1']], 'php'),
 		'title' => $category->name(),
 	];
 }
 
-$feeds = array();
+$feeds = [];
 foreach ($this->feeds as $feed) {
-	$feeds[] = array(
-		'url' => Minz_Url::display(array('c' => 'feed', 'a' => 'actualize', 'params' => array('id' => $feed->id(), 'ajax' => '1')), 'php'),
+	$feeds[] = [
+		'url' => Minz_Url::display(['c' => 'feed', 'a' => 'actualize', 'params' => ['id' => $feed->id(), 'ajax' => '1']], 'php'),
 		'title' => $feed->name(),
-	);
+	];
 }
-echo json_encode(array(
+echo json_encode([
 	'categories' => $categories,
 	'feeds' => $feeds,
 	'feedback_no_refresh' => _t('feedback.sub.feed.no_refresh'),
 	'feedback_actualize' => _t('feedback.sub.actualize'),
-));
+]);

+ 2 - 2
app/views/user/profile.phtml

@@ -60,8 +60,8 @@
 			<div class="group-controls">
 				<input type="text" id="token" name="token" value="<?= $token ?>" placeholder="<?= _t('gen.short.blank_to_disable') ?>" data-leave-validation="<?= $token ?>"/>
 				<p class="help"><?= _i('help') ?> <?= _t('admin.auth.token_help') ?></p>
-				<kbd><?= Minz_Url::display(array('a' => 'rss', 'params' => array('user' => Minz_User::name(),
-					'token' => $token, 'hours' => FreshRSS_Context::userConf()->since_hours_posts_per_rss)), 'html', true) ?></kbd>
+				<kbd><?= Minz_Url::display(['a' => 'rss', 'params' => ['user' => Minz_User::name() ?? '',
+					'token' => $token, 'hours' => FreshRSS_Context::userConf()->since_hours_posts_per_rss]], 'html', true) ?></kbd>
 				<p class="help"><?= _i('help') ?> <?= _t('conf.query.help') ?></a></p>
 			</div>
 		</div>

+ 5 - 5
composer.lock

@@ -314,16 +314,16 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.11.3",
+            "version": "1.11.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "e64220a05c1209fc856d58e789c3b7a32c0bb9a5"
+                "reference": "9100a76ce8015b9aa7125b9171ae3a76887b6c82"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e64220a05c1209fc856d58e789c3b7a32c0bb9a5",
-                "reference": "e64220a05c1209fc856d58e789c3b7a32c0bb9a5",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9100a76ce8015b9aa7125b9171ae3a76887b6c82",
+                "reference": "9100a76ce8015b9aa7125b9171ae3a76887b6c82",
                 "shasum": ""
             },
             "require": {
@@ -368,7 +368,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-05-31T13:53:37+00:00"
+            "time": "2024-06-06T12:19:22+00:00"
         },
         {
             "name": "phpstan/phpstan-phpunit",

+ 8 - 6
lib/Minz/Helper.php

@@ -9,23 +9,25 @@ declare(strict_types=1);
 /**
  * The Minz_Helper class contains some misc. help functions
  */
-class Minz_Helper {
+final class Minz_Helper {
 
 	/**
 	 * Wrapper for htmlspecialchars.
-	 * Force UTf-8 value and can be used on array too.
+	 * Force UTF-8 value and can be used on array too.
 	 *
-	 * @phpstan-template T of string|array<mixed>
+	 * @phpstan-template T of mixed
 	 * @phpstan-param T $var
 	 * @phpstan-return T
 	 *
-	 * @param string|array<mixed> $var
-	 * @return string|array<mixed>
+	 * @param mixed $var
+	 * @return mixed
 	 */
 	public static function htmlspecialchars_utf8($var) {
 		if (is_array($var)) {
-			return array_map(['Minz_Helper', 'htmlspecialchars_utf8'], $var);
+			// @phpstan-ignore argument.type, return.type
+			return array_map([self::class, 'htmlspecialchars_utf8'], $var);
 		} elseif (is_string($var)) {
+			// @phpstan-ignore return.type
 			return htmlspecialchars($var, ENT_COMPAT, 'UTF-8');
 		} else {
 			return $var;

+ 1 - 1
lib/Minz/Model.php

@@ -9,6 +9,6 @@ declare(strict_types=1);
 /**
  * The Minz_Model class represents a model in the MVC paradigm.
  */
-class Minz_Model {
+abstract class Minz_Model {
 
 }

+ 20 - 21
lib/Minz/Request.php

@@ -19,7 +19,7 @@ class Minz_Request {
 	private static string $default_controller_name = 'index';
 	private static string $default_action_name = 'index';
 
-	/** @var array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} */
+	/** @var array{c?:string,a?:string,params?:array<string,mixed>} */
 	private static array $originalRequest = [];
 
 	/**
@@ -35,6 +35,7 @@ class Minz_Request {
 	public static function params(): array {
 		return self::$params;
 	}
+
 	/**
 	 * Read the URL parameter
 	 * @param string $key Key name
@@ -46,10 +47,8 @@ class Minz_Request {
 	public static function param(string $key, $default = false, bool $specialchars = false) {
 		if (isset(self::$params[$key])) {
 			$p = self::$params[$key];
-			if (is_object($p) || $specialchars) {
-				return $p;
-			} elseif (is_string($p) || is_array($p)) {
-				return Minz_Helper::htmlspecialchars_utf8($p);
+			if (is_string($p) || is_array($p)) {
+				return $specialchars ? $p : Minz_Helper::htmlspecialchars_utf8($p);
 			} else {
 				return $p;
 			}
@@ -58,12 +57,15 @@ class Minz_Request {
 		}
 	}
 
-	/** @return array<string|int,string|array<string,string|int>> */
+	public static function hasParam(string $key): bool {
+		return isset(self::$params[$key]);
+	}
+
+	/** @return array<string|int,string|array<string,string|int|bool>> */
 	public static function paramArray(string $key, bool $specialchars = false): array {
 		if (empty(self::$params[$key]) || !is_array(self::$params[$key])) {
 			return [];
 		}
-
 		return $specialchars ? Minz_Helper::htmlspecialchars_utf8(self::$params[$key]) : self::$params[$key];
 	}
 
@@ -131,7 +133,7 @@ class Minz_Request {
 	public static function defaultActionName(): string {
 		return self::$default_action_name;
 	}
-	/** @return array{'c':string,'a':string,'params':array<string,mixed>} */
+	/** @return array{c:string,a:string,params:array<string,mixed>} */
 	public static function currentRequest(): array {
 		return [
 			'c' => self::$controller_name,
@@ -140,14 +142,14 @@ class Minz_Request {
 		];
 	}
 
-	/** @return array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} */
+	/** @return array{c?:string,a?:string,params?:array<string,mixed>} */
 	public static function originalRequest() {
 		return self::$originalRequest;
 	}
 
 	/**
 	 * @param array<string,mixed>|null $extraParams
-	 * @return array{'c':string,'a':string,'params':array<string,mixed>}
+	 * @return array{c:string,a:string,params:array<string,mixed>}
 	 */
 	public static function modifiedCurrentRequest(?array $extraParams = null): array {
 		unset(self::$params['ajax']);
@@ -169,14 +171,13 @@ class Minz_Request {
 		self::$action_name = ctype_alnum($action_name) ? $action_name : '';
 	}
 
-	/** @param array<string,string> $params */
+	/** @param array<string,mixed> $params */
 	public static function _params(array $params): void {
 		self::$params = $params;
 	}
 
-	/** @param array|mixed $value */
-	public static function _param(string $key, $value = false): void {
-		if ($value === false) {
+	public static function _param(string $key, ?string $value = null): void {
+		if ($value === null) {
 			unset(self::$params[$key]);
 		} else {
 			self::$params[$key] = $value;
@@ -382,7 +383,7 @@ class Minz_Request {
 
 	/**
 	 * Restart a request
-	 * @param array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} $url an array presentation of the URL to route to
+	 * @param array{c?:string,a?:string,params?:array<string,mixed>} $url an array presentation of the URL to route to
 	 * @param bool $redirect If true, uses an HTTP redirection, and if false (default), performs an internal dispatcher redirection.
 	 * @throws Minz_ConfigurationException
 	 */
@@ -400,10 +401,8 @@ class Minz_Request {
 		} else {
 			self::_controllerName($url['c']);
 			self::_actionName($url['a']);
-			self::_params(array_merge(
-				self::$params,
-				$url['params']
-			));
+			$merge = array_merge(self::$params, $url['params']);
+			self::_params($merge);
 			Minz_Dispatcher::reset();
 		}
 	}
@@ -411,7 +410,7 @@ class Minz_Request {
 	/**
 	 * Wrappers good notifications + redirection
 	 * @param string $msg notification content
-	 * @param array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} $url url array to where we should be forwarded
+	 * @param array{c?:string,a?:string,params?:array<string,mixed>} $url url array to where we should be forwarded
 	 */
 	public static function good(string $msg, array $url = []): void {
 		Minz_Request::setGoodNotification($msg);
@@ -421,7 +420,7 @@ class Minz_Request {
 	/**
 	 * Wrappers bad notifications + redirection
 	 * @param string $msg notification content
-	 * @param array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} $url url array to where we should be forwarded
+	 * @param array{c?:string,a?:string,params?:array<string,mixed>} $url url array to where we should be forwarded
 	 */
 	public static function bad(string $msg, array $url = []): void {
 		Minz_Request::setBadNotification($msg);

+ 16 - 21
lib/Minz/Url.php

@@ -7,7 +7,7 @@ declare(strict_types=1);
 class Minz_Url {
 	/**
 	 * Display a formatted URL
-	 * @param string|array<string,string|array<string,mixed>> $url The URL to format, defined as an array:
+	 * @param string|array{c?:string,a?:string,params?:array<string,mixed>} $url The URL to format, defined as an array:
 	 *                    $url['c'] = controller
 	 *                    $url['a'] = action
 	 *                    $url['params'] = array of additional parameters
@@ -26,7 +26,7 @@ class Minz_Url {
 
 		$url_string = '';
 
-		if ($absolute) {
+		if ($absolute !== false) {
 			$url_string = Minz_Request::getBaseUrl();
 			if (strlen($url_string) < strlen('http://a.bc')) {
 				$url_string = Minz_Request::guessBaseUrl();
@@ -58,7 +58,7 @@ class Minz_Url {
 
 	/**
 	 * Construit l'URI d'une URL
-	 * @param array<string,mixed> $url l'url sous forme de tableau
+	 * @param array{c:string,a:string,params:array<string,mixed>} $url URL as array definition
 	 * @param string $encodage pour indiquer comment encoder les & (& ou &amp; pour html)
 	 * @return string uri sous la forme ?key=value&key2=value2
 	 */
@@ -74,17 +74,19 @@ class Minz_Url {
 		}
 
 		if (!empty($url['params']) && is_array($url['params']) && !empty($url['params']['#'])) {
-			$anchor = '#' . ($encodage === 'html' ? htmlspecialchars($url['params']['#'], ENT_QUOTES, 'UTF-8') : $url['params']['#']);
+			if (is_string($url['params']['#'])) {
+				$anchor = '#' . ($encodage === 'html' ? htmlspecialchars($url['params']['#'], ENT_QUOTES, 'UTF-8') : $url['params']['#']);
+			}
 			unset($url['params']['#']);
 		}
 
-		if (isset($url['c'])
+		if (isset($url['c']) && is_string($url['c'])
 		 && $url['c'] != Minz_Request::defaultControllerName()) {
 			$uri .= $separator . 'c=' . $url['c'];
 			$separator = $and;
 		}
 
-		if (isset($url['a'])
+		if (isset($url['a']) && is_string($url['a'])
 		 && $url['a'] != Minz_Request::defaultActionName()) {
 			$uri .= $separator . 'a=' . $url['a'];
 			$separator = $and;
@@ -94,7 +96,7 @@ class Minz_Url {
 			unset($url['params']['c']);
 			unset($url['params']['a']);
 			foreach ($url['params'] as $key => $param) {
-				if (!is_string($key) || (!is_string($param) && !is_int($param))) {
+				if (!is_string($key) || (!is_string($param) && !is_int($param) && !is_bool($param))) {
 					continue;
 				}
 				$uri .= $separator . urlencode($key) . '=' . urlencode((string)$param);
@@ -102,10 +104,6 @@ class Minz_Url {
 			}
 		}
 
-		if (!empty($url['#']) && is_string($url['#'])) {
-			$uri .= '#' . ($encodage === 'html' ? htmlspecialchars($url['#'], ENT_QUOTES, 'UTF-8') : $url['#']);
-		}
-
 		$uri .= $anchor;
 
 		return $uri;
@@ -113,8 +111,8 @@ class Minz_Url {
 
 	/**
 	 * Check that all array elements representing the controller URL are OK
-	 * @param array<string,string|array<string,mixed>> $url controller URL as array
-	 * @return array{'c':string,'a':string,'params':array<string,mixed>} Verified controller URL as array
+	 * @param array{c?:string,a?:string,params?:array<string,mixed>} $url controller URL as array
+	 * @return array{c:string,a:string,params:array<string,mixed>} Verified controller URL as array
 	 */
 	public static function checkControllerUrl(array $url): array {
 		return [
@@ -124,7 +122,7 @@ class Minz_Url {
 		];
 	}
 
-	/** @param array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} $url */
+	/** @param array{c?:string,a?:string,params?:array<string,mixed>} $url */
 	public static function serialize(?array $url = []): string {
 		if (empty($url)) {
 			return '';
@@ -136,19 +134,16 @@ class Minz_Url {
 		}
 	}
 
-	/**
-	 * @phpstan-return array{'c'?:string,'a'?:string,'params'?:array<string,mixed>}
-	 * @return array<string,string|array<string,string>>
-	 */
+	/** @return array{c?:string,a?:string,params?:array<string,mixed>} */
 	public static function unserialize(string $url = ''): array {
 		$result = json_decode(base64_decode($url, true) ?: '', true, JSON_THROW_ON_ERROR) ?? [];
-		/** @var array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} $result */
+		/** @var array{c?:string,a?:string,params?:array<string,mixed>} $result */
 		return $result;
 	}
 
 	/**
 	 * Returns an array representing the URL as passed in the address bar
-	 * @return array{'c'?:string,'a'?:string,'params'?:array<string,mixed>} URL representation
+	 * @return array{c?:string,a?:string,params?:array<string,string>} URL representation
 	 */
 	public static function build(): array {
 		$url = [
@@ -184,5 +179,5 @@ function _url(string $controller, string $action, ...$args) {
 		$params[$arg] = '' . $args[$i + 1];
 	}
 
-	return Minz_Url::display (array ('c' => $controller, 'a' => $action, 'params' => $params));
+	return Minz_Url::display(['c' => $controller, 'a' => $action, 'params' => $params]);
 }

+ 1 - 1
lib/favicons.php

@@ -75,7 +75,7 @@ function searchFavicon(string &$url): string {
 	$links = $xpath->query('//link[@href][translate(@rel, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="shortcut icon"'
 		. ' or translate(@rel, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="icon"]');
 
-	if (!$links) {
+	if (!($links instanceof DOMNodeList)) {
 		return '';
 	}
 

+ 5 - 3
lib/lib_rss.php

@@ -41,8 +41,7 @@ if (!function_exists('syslog')) {
 		define('STDERR', fopen('php://stderr', 'w'));
 	}
 	function syslog(int $priority, string $message): bool {
-		// @phpstan-ignore booleanAnd.rightAlwaysTrue
-		if (COPY_SYSLOG_TO_STDERR && defined('STDERR') && STDERR) {
+		if (COPY_SYSLOG_TO_STDERR && defined('STDERR') && is_resource(STDERR)) {
 			return fwrite(STDERR, $message . "\n") != false;
 		}
 		return false;
@@ -619,9 +618,12 @@ function lazyimg(string $content): string {
 	) ?? '';
 }
 
+/** @return numeric-string */
 function uTimeString(): string {
 	$t = @gettimeofday();
-	return $t['sec'] . str_pad('' . $t['usec'], 6, '0', STR_PAD_LEFT);
+	$result = $t['sec'] . str_pad('' . $t['usec'], 6, '0', STR_PAD_LEFT);
+	/** @var numeric-string @result */
+	return $result;
 }
 
 function invalidateHttpCache(string $username = ''): bool {

+ 6 - 1
p/api/fever.php

@@ -407,7 +407,7 @@ final class FeverAPI
 	}
 
 	/**
-	 * @param array<string> $ids
+	 * @param array<numeric-string> $ids
 	 */
 	private function entriesToIdList(array $ids = []): string {
 		return implode(',', array_values($ids));
@@ -424,6 +424,7 @@ final class FeverAPI
 	}
 
 	/**
+	 * @param numeric-string $id
 	 * @return int|false
 	 */
 	private function setItemAsRead(string $id) {
@@ -431,6 +432,7 @@ final class FeverAPI
 	}
 
 	/**
+	 * @param numeric-string $id
 	 * @return int|false
 	 */
 	private function setItemAsUnread(string $id) {
@@ -438,6 +440,7 @@ final class FeverAPI
 	}
 
 	/**
+	 * @param numeric-string $id
 	 * @return int|false
 	 */
 	private function setItemAsSaved(string $id) {
@@ -445,6 +448,7 @@ final class FeverAPI
 	}
 
 	/**
+	 * @param numeric-string $id
 	 * @return int|false
 	 */
 	private function setItemAsUnsaved(string $id) {
@@ -527,6 +531,7 @@ final class FeverAPI
 
 	/**
 	 * TODO replace by a dynamic fetch for id <= $before timestamp
+	 * @return numeric-string
 	 */
 	private function convertBeforeToId(int $beforeTimestamp): string {
 		return $beforeTimestamp == 0 ? '0' : $beforeTimestamp . '000000';

+ 11 - 2
p/api/greader.php

@@ -31,11 +31,15 @@ require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
 $ORIGINAL_INPUT = file_get_contents('php://input', false, null, 0, 1048576) ?: '';
 
 if (PHP_INT_SIZE < 8) {	//32-bit
+	/** @return numeric-string */
 	function hex2dec(string $hex): string {
 		if (!ctype_xdigit($hex)) return '0';
-		return gmp_strval(gmp_init($hex, 16), 10);
+		$result = gmp_strval(gmp_init($hex, 16), 10);
+		/** @var numeric-string $result */
+		return $result;
 	}
 } else {	//64-bit
+	/** @return numeric-string */
 	function hex2dec(string $hex): string {
 		if (!ctype_xdigit($hex)) {
 			return '0';
@@ -794,6 +798,7 @@ final class GReaderAPI {
 				$e_ids[$i] = hex2dec(basename($e_id));	//Strip prefix 'tag:google.com,2005:reader/item/'
 			}
 		}
+		/** @var array<numeric-string> $e_ids */
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entries = $entryDAO->listByIds($e_ids, $order === 'o' ? 'ASC' : 'DESC');
@@ -822,6 +827,7 @@ final class GReaderAPI {
 				$e_ids[$i] = hex2dec(basename($e_id));	//Strip prefix 'tag:google.com,2005:reader/item/'
 			}
 		}
+		/** @var array<numeric-string> $e_ids */
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$tagDAO = FreshRSS_Factory::createTagDao();
@@ -943,7 +949,10 @@ final class GReaderAPI {
 		self::badRequest();
 	}
 
-	/** @return never */
+	/**
+	 * @param numeric-string $olderThanId
+	 * @return never
+	 */
 	private static function markAllAsRead(string $streamId, string $olderThanId) {
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		if (strpos($streamId, 'feed/') === 0) {

+ 1 - 1
p/api/pshb.php

@@ -67,7 +67,7 @@ if (empty($users)) {
 }
 
 if (!empty($_REQUEST['hub_mode']) && $_REQUEST['hub_mode'] === 'subscribe') {
-	$leaseSeconds = empty($_REQUEST['hub_lease_seconds']) ? 0 : (int) $_REQUEST['hub_lease_seconds'];
+	$leaseSeconds = empty($_REQUEST['hub_lease_seconds']) ? 0 : (int)$_REQUEST['hub_lease_seconds'];
 	if ($leaseSeconds > 60) {
 		$hubJson['lease_end'] = time() + $leaseSeconds;
 	} else {

+ 1 - 1
p/api/query.php

@@ -82,7 +82,7 @@ foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
 		if (Minz_Request::paramString('order') === '') {
 			Minz_Request::_param('order', $query->getOrder());
 		}
-		Minz_Request::_param('state', $query->getState());
+		Minz_Request::_param('state', (string)$query->getState());
 
 		$search = $query->getSearch()->getRawInput();
 		// Note: we disallow references to user queries in public user search to avoid sniffing internal user queries

+ 29 - 6
phpstan-next.neon

@@ -2,17 +2,40 @@ includes:
 	- phpstan.neon
 
 parameters:
-	level: 9
+	strictRules:
+		booleansInConditions: true
+	ignoreErrors:
+		- '#Only booleans are allowed in (a negated boolean|a ternary operator condition|an elseif condition|an if condition|&&|\|\|), (bool|false|int(<[0-9, max]+>)?|true|null|\|)+ given.*#'
 	excludePaths:
 		analyse:
 			# TODO: Update files below and remove them from this list
-			- app/Controllers/configureController.php
+			- app/Controllers/feedController.php
 			- app/Controllers/importExportController.php
-			- app/Models/DatabaseDAO.php
+			- app/Controllers/subscriptionController.php
+			- app/Controllers/tagController.php
+			- app/FreshRSS.php
+			- app/Models/CategoryDAO.php
+			- app/Models/CategoryDAOSQLite.php
+			- app/Models/DatabaseDAOSQLite.php
 			- app/Models/Entry.php
+			- app/Models/EntryDAO.php
+			- app/Models/EntryDAOSQLite.php
 			- app/Models/Feed.php
+			- app/Models/FeedDAO.php
+			- app/Models/FeedDAOSQLite.php
+			- app/Models/FormAuth.php
+			- app/Models/LogDAO.php
+			- app/Models/TagDAO.php
+			- app/Models/Themes.php
+			- app/Services/ExportService.php
 			- app/Services/ImportService.php
 			- app/views/configure/archiving.phtml
-			- app/views/helpers/feed/update.phtml
-			- lib/Minz/Helper.php
-			- lib/Minz/Request.php
+			- app/views/configure/queries.phtml
+			- app/views/configure/query.phtml
+			- app/views/helpers/stream-footer.phtml
+			- app/views/stats/repartition.phtml
+			- app/views/subscription/feed.phtml
+			- cli/CliOptionsParser.php
+			- cli/create-user.php
+			- cli/update-user.php
+			- lib/lib_rss.php

+ 1 - 2
phpstan.neon

@@ -1,6 +1,5 @@
 parameters:
-	# TODO: Increase rule-level https://phpstan.org/user-guide/rule-levels
-	level: 8
+	level: 9	# https://phpstan.org/user-guide/rule-levels
 	phpVersion: 80399	# TODO: Remove line when moving composer.json to PHP 8+
 	fileExtensions:
 		- php

+ 5 - 0
tests/app/Models/SearchTest.php

@@ -332,6 +332,11 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 				'(e.title LIKE ? )',
 				['%(test)%'],
 			],
+			[
+				'intitle:\'"hello world"\'',
+				'(e.title LIKE ? )',
+				['%"hello world"%'],
+			],
 		];
 	}
 }