From 2276fb5d2be3b01232e7684b50cc9a8d94070e9e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 27 Apr 2026 20:27:55 +0200 Subject: [PATCH] refactor and code clean-up --- composer.json | 7 + composer.lock | 205 ++++- phpstan-baseline.neon | 709 ++++++++++++++++++ phpstan.neon.dist | 17 + src/Command/ArticleHighlightsAuditCommand.php | 8 +- src/Command/PrewarmCommand.php | 10 +- src/Controller/ArticleController.php | 37 +- src/Controller/AuthorController.php | 11 +- src/Controller/EventController.php | 6 +- src/Form/RoleType.php | 4 +- src/Nostr/MagazineEventKeys.php | 4 +- src/Security/NostrAuthenticator.php | 11 +- src/Service/ArticleBodyHighlightInjector.php | 6 +- src/Service/CacheService.php | 4 +- src/Service/CommentReplyService.php | 5 +- src/Service/FeaturedAuthorListedRows.php | 8 +- src/Service/FeaturedAuthorSync.php | 19 +- src/Service/MagazineContentService.php | 10 - src/Service/MagazineRefresher.php | 2 +- src/Service/Nip05VerificationService.php | 73 +- src/Service/Nip09DeletionApplier.php | 4 +- src/Service/NostrClient.php | 9 +- src/Service/NostrKeyHelper.php | 41 + src/Service/NostrPathHelper.php | 4 +- src/Service/NostrShareMenuBuilder.php | 18 +- src/Twig/Components/IndexTabs.php | 1 - .../Components/Molecules/UserFromNpub.php | 13 +- src/Twig/MagazineJumbleExtension.php | 6 +- .../NostrEventRenderer.php | 4 +- symfony.lock | 9 + tests/Security/NostrAuthenticatorTest.php | 7 +- .../ArticleBodyHighlightInjectorTest.php | 3 +- ...ArticleHighlightCommonMarkPipelineTest.php | 3 +- tests/phpstan-doctrine-object-manager.php | 29 + 34 files changed, 1156 insertions(+), 151 deletions(-) create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 src/Service/NostrKeyHelper.php create mode 100644 tests/phpstan-doctrine-object-manager.php diff --git a/composer.json b/composer.json index bb7bbe0..433ebd1 100644 --- a/composer.json +++ b/composer.json @@ -94,6 +94,10 @@ ], "post-update-cmd": [ "@auto-scripts" + ], + "phpstan": [ + "@php bin/console cache:warmup --env=dev --no-interaction", + "phpstan analyse -c phpstan.neon.dist --memory-limit=512M" ] }, "conflict": { @@ -115,6 +119,9 @@ } }, "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^9.5", "symfony/browser-kit": "7.3.*", "symfony/css-selector": "7.3.*", diff --git a/composer.lock b/composer.lock index a99afed..857e40a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "138ac13dfe47c4f697e248fc51668cf4", + "content-hash": "7921811e20bbf491efec9a7357aae9ab", "packages": [ { "name": "bitwasp/bech32", @@ -10240,6 +10240,209 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.51", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-04-21T18:22:01+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.21", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "81dac0ee4363c2359128aec844df31efb215dddc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/81dac0ee4363c2359128aec844df31efb215dddc", + "reference": "81dac0ee4363c2359128aec844df31efb215dddc", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.34" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.4.3", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.21" + }, + "time": "2026-04-17T13:00:39+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.15", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/9b85ab476969b87bbe2253b69e265a9359b2f395", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.15" + }, + "time": "2026-02-26T10:15:59+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.32", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..0875db9 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,709 @@ +parameters: + ignoreErrors: + - + message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: src/Command/ArticleHighlightsAuditCommand.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Command/ArticleHighlightsAuditCommand.php + + - + message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Command/NostrEventFromYamlDefinitionCommand.php + + - + message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:save\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Command/NostrEventFromYamlDefinitionCommand.php + + - + message: '#^Call to function is_array\(\) with bool\|int\|string\|null will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: src/Command/PrewarmCommand.php + + - + message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''clear'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''setMinSecondsBetwee…'' will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''categories'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''categories'' on array\{categories\: list\\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''entries'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''event_id'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\\>\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''listed'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''listed_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''missing'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''missing_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''reason'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''resolved'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''resolved_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''slug'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''title'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset ''totals'' on array\{categories\: list\\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Strict comparison using \=\=\= between \*NEVER\* and 1 will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Strict comparison using \=\=\= between array\{\} and array\{\} will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Command/PrewarmCommand.php + + - + message: '#^Call to an undefined method Symfony\\Component\\Form\\FormInterface\\:\:getClickedButton\(\)\.$#' + identifier: method.notFound + count: 3 + path: src/Controller/ArticleController.php + + - + message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getMetadata\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Offset ''comment_reply…'' on array\{list\: array\, quotes\: array\, commentLinks\: array\\>, quoteLinks\: array\\>, processedContent\: array\, comment_reply_context\: array\{can_publish\: bool, coordinate\: string, article_event_id\: string\|null, parent_kind\: int, rows\: array\\>, fragment_url\: string\}\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Offset ''list'' on array\{list\: array\, quotes\: array\, commentLinks\: array\\>, quoteLinks\: array\\>, processedContent\: array\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Offset 0 on non\-empty\-list\ in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Controller/ArticleController.php + + - + message: '#^Offset ''ok_relays'' on array\{ok\: true, id\: string, relays\: array\, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/CommentReplyController.php + + - + message: '#^Offset ''total_relays'' on array\{ok\: true, id\: string, relays\: array\, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/CommentReplyController.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Controller/DefaultController.php + + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Controller/SeoController.php + + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Controller/SeoController.php + + - + message: '#^Offset ''list'' on array\{list\: list\, category\: array\{title\: string, summary\: string\}, pagination\: array\{page\: int, per_page\: int, total\: int, last_page\: int\}\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/SeoController.php + + - + message: '#^Offset ''summary'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/SeoController.php + + - + message: '#^Offset ''title'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Controller/SeoController.php + + - + message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php + + - + message: '#^Call to function is_string\(\) with non\-empty\-string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php + + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Nostr/MagazineEventKeys.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 3 + path: src/Nostr/Nip19Codec.php + + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Nostr/Nip22CommentTags.php + + - + message: '#^Comparison operation "\>\=" between int\<1, max\> and 1 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: src/Nostr/Nip22CommentTags.php + + - + message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 2 + path: src/Service/ArticleBodyHighlightInjector.php + + - + message: '#^Instanceof between DOMElement and DOMElement will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: src/Service/ArticleBodyHighlightInjector.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Service/ArticleBodyHighlightInjector.php + + - + message: '#^Strict comparison using \=\=\= between false and DOMElement will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/Service/ArticleBodyHighlightInjector.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset ''partial'' on array\{thread\: array\, quotes\: array\\} on left side of \?\? does not exist\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset ''quotes'' on array\{thread\: array\, quotes\: array\, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset ''quotes'' on array\{thread\: array\, quotes\: array\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset ''thread'' on array\{thread\: array\, quotes\: array\, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset ''thread'' on array\{thread\: array\, quotes\: array\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ArticleCommentThreadLoader.php + + - + message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: src/Service/CacheService.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Service/CacheService.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/CacheService.php + + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: src/Service/CacheService.php + + - + message: '#^Strict comparison using \!\=\= between non\-empty\-list\ and array\{\} will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: src/Service/CacheService.php + + - + message: '#^Comparison operation "\>\=" between 3 and 2 is always true\.$#' + identifier: greaterOrEqual.alwaysTrue + count: 1 + path: src/Service/CommentReplyService.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/HighlightSyncService.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Service/HighlightSyncService.php + + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 3 + path: src/Service/MagazineContentService.php + + - + message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset ''categories'' on array\{categories\: list\\}\>\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: ''missing'', reason\: ''article_not_in_db''\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset ''entries'' on array\{entries\: list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset ''reason'' on array\{coordinate\: string, status\: ''missing'', reason\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/MagazineContentService.php + + - + message: '#^Cannot call method __invoke\(\) on callable\.$#' + identifier: method.nonObject + count: 4 + path: src/Service/MagazineRefresher.php + + - + message: '#^Offset ''label'' on array\{label\: string, href\: string, verified\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/Nip05VerificationService.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/Nip09DeletionApplier.php + + - + message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getRelays\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Service/NostrClient.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 2 + path: src/Service/NostrClient.php + + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Call to function is_array\(\) with list\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 9 + path: src/Service/NostrClient.php + + - + message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 11 + path: src/Service/NostrClient.php + + - + message: '#^Call to function method_exists\(\) with swentel\\nostr\\Request\\Request and ''setTimeout'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Method App\\Service\\NostrClient\:\:fetchKind5DeletionEventsForAuthors\(\) has invalid return type App\\Service\\stdClass\.$#' + identifier: class.notFound + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Offset ''kind'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\\} in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^PHPDoc tag @return with type swentel\\nostr\\Event\\Event\|null is not subtype of native type stdClass\|null\.$#' + identifier: return.phpDocType + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 4 + path: src/Service/NostrClient.php + + - + message: '#^Result of \|\| is always false\.$#' + identifier: booleanOr.alwaysFalse + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Strict comparison using \!\=\= between non\-empty\-list\ and array\{\} will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Strict comparison using \=\=\= between non\-empty\-list\ and array\{\} will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^Strict comparison using \=\=\= between non\-empty\-list\ and array\{\} will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/Service/NostrClient.php + + - + message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\\|list\ is not subtype of native type object\.$#' + identifier: parameter.phpDocType + count: 1 + path: src/Service/NostrShareMenuBuilder.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: src/Service/NostrShareMenuBuilder.php + + - + message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Service/ProfileIdentityLinksBuilder.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 2 + path: src/Service/ProfileIdentityLinksBuilder.php + + - + message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Service/ProfilePaymentLinksBuilder.php + + - + message: '#^Offset non\-falsy\-string on array\{\} in isset\(\) does not exist\.$#' + identifier: isset.offset + count: 1 + path: src/Service/ProfilePaymentLinksBuilder.php + + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: src/Service/ProfilePaymentLinksBuilder.php + + - + message: '#^Strict comparison using \!\=\= between non\-empty\-list\ and array\{\} will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: src/Service/ProfilePaymentLinksBuilder.php + + - + message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\\.$#' + identifier: method.protected + count: 1 + path: src/Service/TopicIndexService.php + + - + message: '#^Property App\\Twig\\Components\\IndexTabs\:\:\$index is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Twig/Components/IndexTabs.php + + - + message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Twig/Components/Molecules/CategoryLink.php + + - + message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Twig/Components/Organisms/FeaturedList.php + + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Util/NostrEventTags.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$eventIdsLowerOrMixed$#' + identifier: parameter.notFound + count: 1 + path: tests/Service/ArticleBodyHighlightInjectorTest.php + + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 1 + path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php + + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php + + - + message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/bootstrap.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..00843f5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +# Dependency notes (composer why): +# - phpdocumentor/reflection-docblock: required directly; symfony/* constrain <6 +# - phpstan/phpdoc-parser: direct + reflection-docblock / type-resolver +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-doctrine/extension.neon + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - tests + symfony: + containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml + doctrine: + objectManagerLoader: tests/phpstan-doctrine-object-manager.php diff --git a/src/Command/ArticleHighlightsAuditCommand.php b/src/Command/ArticleHighlightsAuditCommand.php index a8035e6..a90035c 100644 --- a/src/Command/ArticleHighlightsAuditCommand.php +++ b/src/Command/ArticleHighlightsAuditCommand.php @@ -9,8 +9,8 @@ use App\Repository\ArticleHighlightRepository; use App\Repository\ArticleRepository; use App\Service\ArticleBodyHighlightInjector; use App\Util\CommonMark\Converter; +use App\Service\NostrKeyHelper; use League\CommonMark\Exception\CommonMarkException; -use swentel\nostr\Key\Key; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -34,6 +34,7 @@ final class ArticleHighlightsAuditCommand extends Command private readonly ArticleHighlightRepository $articleHighlightRepository, private readonly Converter $converter, private readonly ArticleBodyHighlightInjector $articleBodyHighlightInjector, + private readonly NostrKeyHelper $nostrKeyHelper, ) { parent::__construct(); } @@ -62,11 +63,10 @@ final class ArticleHighlightsAuditCommand extends Command return Command::FAILURE; } - $key = new Key(); - $expectedNpub = $key->convertPublicKeyToBech32((string) $article->getPubkey()); + $expectedNpub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey()); $optNpub = $input->getOption('npub'); if (\is_string($optNpub) && $optNpub !== '') { - if ($key->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) { + if ($this->nostrKeyHelper->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) { $io->error('npub does not match this article’s author (expected: '.$expectedNpub.').'); return Command::FAILURE; diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index e8f4458..bffbe7e 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -16,9 +16,9 @@ use App\Service\HighlightSyncService; use App\Service\MagazineRefresher; use App\Service\Nip09DeletionApplier; use App\Service\NostrClient; +use App\Service\NostrKeyHelper; use App\Service\ProfileIdentityLinksBuilder; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Helper; @@ -55,6 +55,7 @@ final class PrewarmCommand extends Command private readonly ProfileIdentityLinksBuilder $profileIdentityLinks, private readonly FeaturedAuthorRepository $featuredAuthorRepository, private readonly HighlightSyncService $highlightSyncService, + private readonly NostrKeyHelper $nostrKeyHelper, ) { parent::__construct(); } @@ -87,7 +88,6 @@ final class PrewarmCommand extends Command } $io = new SymfonyStyle($input, $output); - $keys = new Key(); if (!$input->getOption('no-magazine')) { $budget = max(1, (int) $input->getOption('magazine-budget')); @@ -252,7 +252,7 @@ final class PrewarmCommand extends Command $npubParam = (string) $this->params->get('npub'); if (str_starts_with($npubParam, 'npub')) { try { - $sitePk = $keys->convertToHex($npubParam); + $sitePk = $this->nostrKeyHelper->convertToHex($npubParam); if ($sitePk !== '' && 64 === \strlen($sitePk) && !\in_array($sitePk, $deletionPubkeys, true)) { $deletionPubkeys[] = $sitePk; } @@ -307,7 +307,7 @@ final class PrewarmCommand extends Command $npubParam = (string) $this->params->get('npub'); if (str_starts_with($npubParam, 'npub')) { try { - $sitePk = $keys->convertToHex($npubParam); + $sitePk = $this->nostrKeyHelper->convertToHex($npubParam); if ($sitePk !== '' && !\in_array($sitePk, $pubkeys, true)) { $pubkeys[] = $sitePk; } @@ -385,7 +385,7 @@ final class PrewarmCommand extends Command continue; } $hex = strtolower($hex); - $npub = $keys->convertPublicKeyToBech32($hex); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex); $bundle = $this->cacheService->getMetadataBundle($npub); $rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []); $fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex); diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 0a47acd..17e2c7b 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -11,6 +11,7 @@ use App\Nostr\Nip22CommentTags; use App\Form\EditorType; use App\Service\ArticleCommentThreadLoader; use App\Service\NostrClient; +use App\Service\NostrKeyHelper; use App\Service\CacheService; use App\Nostr\Nip19Codec; use App\Util\CommonMark\Converter; @@ -19,7 +20,6 @@ use League\CommonMark\Exception\CommonMarkException; use Psr\Log\LoggerInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; -use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -252,7 +252,7 @@ class ArticleController extends AbstractController * @throws \Exception */ #[Route('/article/{naddr}', name: 'article-naddr')] - public function naddr(NostrClient $nostrClient, Nip19Codec $nip19, $naddr) + public function naddr(NostrClient $nostrClient, Nip19Codec $nip19, NostrKeyHelper $nostrKeyHelper, $naddr) { $decoded = $nip19->decode($naddr); @@ -273,7 +273,7 @@ class ArticleController extends AbstractController $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); if ($slug) { - $npub = (new Key())->convertPublicKeyToBech32((string) $author); + $npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $author); return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY); } @@ -300,13 +300,13 @@ class ArticleController extends AbstractController ArticleCommentThreadLoader $commentThreadLoader, ArticleHighlightRepository $articleHighlightRepository, ArticleBodyHighlightInjector $articleBodyHighlightInjector, + NostrKeyHelper $nostrKeyHelper, ): Response { $article = $this->loadLatestArticleBySlug($entityManager, $slug); if ($article === null) { throw $this->createNotFoundException('The article could not be found'); } - $key = new Key(); - if ($key->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { + if ($nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { throw $this->createNotFoundException('The article could not be found'); } @@ -316,7 +316,8 @@ class ArticleController extends AbstractController $converter, $commentThreadLoader, $articleHighlightRepository, - $articleBodyHighlightInjector + $articleBodyHighlightInjector, + $nostrKeyHelper ); } @@ -332,13 +333,13 @@ class ArticleController extends AbstractController public function articleLegacyRedirect( string $slug, EntityManagerInterface $entityManager, + NostrKeyHelper $nostrKeyHelper, ): Response { $article = $this->loadLatestArticleBySlug($entityManager, $slug); if ($article === null) { throw $this->createNotFoundException('The article could not be found'); } - $key = new Key(); - $npub = $key->convertPublicKeyToBech32((string) $article->getPubkey()); + $npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey()); return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY); } @@ -358,14 +359,14 @@ class ArticleController extends AbstractController ArticleCommentThreadLoader $commentThreadLoader, ArticleHighlightRepository $articleHighlightRepository, ArticleBodyHighlightInjector $articleBodyHighlightInjector, + NostrKeyHelper $nostrKeyHelper, ): Response { set_time_limit(300); // 5 minutes ini_set('max_execution_time', '300'); $html = $converter->convertToHtml($article->getContent()); - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($article->getPubkey()); + $npub = $nostrKeyHelper->convertPublicKeyToBech32($article->getPubkey()); $author = $cacheService->getMetadata($npub); $kind = $article->getKind()?->value ?? 30023; @@ -441,6 +442,7 @@ class ArticleController extends AbstractController Request $request, NostrClient $nostrClient, CacheService $cacheService, + NostrKeyHelper $nostrKeyHelper, ): Response { $data = $request->getContent(); $descriptor = json_decode($data); @@ -464,8 +466,7 @@ class ArticleController extends AbstractController if (!\is_object($hint) || !isset($hint->pubkey)) { $html = 'Profile preview unavailable.'; } else { - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($hint->pubkey); + $npub = $nostrKeyHelper->convertPublicKeyToBech32($hint->pubkey); $metadata = $cacheService->getMetadata($npub); $metadata->npub = $npub; $metadata->pubkey = $hint->pubkey; @@ -512,7 +513,7 @@ class ArticleController extends AbstractController #[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/edit/{id}', name: 'editor-edit')] public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, - WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response + WorkflowInterface $articlePublishingWorkflow, NostrKeyHelper $nostrKeyHelper, Article $article = null): Response { if (!$article) { $article = new Article(); @@ -529,8 +530,7 @@ class ArticleController extends AbstractController // Step 3: Check if the form is submitted and valid if ($form->isSubmitted() && $form->isValid()) { $user = $this->getUser(); - $key = new Key(); - $currentPubkey = $key->convertToHex($user->getUserIdentifier()); + $currentPubkey = $nostrKeyHelper->convertToHex($user->getUserIdentifier()); if ($article->getPubkey() === null) { $article->setPubkey($currentPubkey); @@ -574,18 +574,17 @@ class ArticleController extends AbstractController */ #[Route('/article-preview/{d}', name: 'article-preview')] public function preview($d, Converter $converter, - CacheItemPoolInterface $articlesCache): Response + CacheItemPoolInterface $articlesCache, NostrKeyHelper $nostrKeyHelper): Response { $user = $this->getUser(); - $key = new Key(); - $currentPubkey = $key->convertToHex($user->getUserIdentifier()); + $currentPubkey = $nostrKeyHelper->convertToHex($user->getUserIdentifier()); $cacheKey = 'article_' . $currentPubkey . '_' . $d; $cacheItem = $articlesCache->getItem($cacheKey); $article = $cacheItem->get(); $content = $converter->convertToHtml($article->getContent()); - $previewNpub = (new Key())->convertPublicKeyToBech32($currentPubkey); + $previewNpub = $nostrKeyHelper->convertPublicKeyToBech32($currentPubkey); return $this->render('pages/article.html.twig', [ 'article' => $article, diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index b72ab15..7701450 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -9,10 +9,10 @@ use App\Repository\FeaturedAuthorRepository; use App\Service\CacheService; use App\Service\Nip05VerificationService; use App\Service\NostrClient; +use App\Service\NostrKeyHelper; use App\Service\ProfileIdentityLinksBuilder; use App\Service\ProfilePaymentLinksBuilder; use Exception; -use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -34,14 +34,14 @@ class AuthorController extends AbstractController Nip05VerificationService $nip05Verification, ProfilePaymentLinksBuilder $profilePaymentLinks, ProfileIdentityLinksBuilder $profileIdentityLinks, + NostrKeyHelper $nostrKeyHelper, ): Response { // Profile pages chain several sequential Nostr REQ runs; match article pages so a slow relay // set does not hit PHP’s default 30s max_execution_time during Twig render. @set_time_limit(300); @ini_set('max_execution_time', '300'); - $keys = new Key(); - $pubkey = $keys->convertToHex($npub); + $pubkey = $nostrKeyHelper->convertToHex($npub); $bundle = $cacheService->getMetadataBundle($npub); $author = $bundle['content']; @@ -93,10 +93,9 @@ class AuthorController extends AbstractController * @throws Exception */ #[Route('/p/{pubkey}', name: 'author-redirect')] - public function authorRedirect($pubkey): Response + public function authorRedirect($pubkey, NostrKeyHelper $nostrKeyHelper): Response { - $keys = new Key(); - $npub = $keys->convertPublicKeyToBech32($pubkey); + $npub = $nostrKeyHelper->convertPublicKeyToBech32($pubkey); return $this->redirectToRoute('author-profile', ['npub' => $npub]); } diff --git a/src/Controller/EventController.php b/src/Controller/EventController.php index 651a3f5..23b1534 100644 --- a/src/Controller/EventController.php +++ b/src/Controller/EventController.php @@ -9,9 +9,9 @@ use App\Service\NostrClient; use App\Service\NostrLinkParser; use App\Service\NostrShareMenuBuilder; use App\Service\CacheService; +use App\Service\NostrKeyHelper; use Exception; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,6 +32,7 @@ class EventController extends AbstractController CacheService $cacheService, NostrLinkParser $nostrLinkParser, NostrShareMenuBuilder $nostrShareMenuBuilder, + NostrKeyHelper $nostrKeyHelper, LoggerInterface $logger, ): Response { $logger->info('Accessing event page', ['nevent' => $nevent]); @@ -107,8 +108,7 @@ class EventController extends AbstractController // If author is included in the event, get metadata $authorMetadata = null; if (isset($event->pubkey)) { - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($event->pubkey); + $npub = $nostrKeyHelper->convertPublicKeyToBech32($event->pubkey); $authorMetadata = $cacheService->getMetadata($npub); } diff --git a/src/Form/RoleType.php b/src/Form/RoleType.php index e8ff869..54dbdc6 100644 --- a/src/Form/RoleType.php +++ b/src/Form/RoleType.php @@ -14,7 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class RoleType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->setAction('/admin/role/add') @@ -27,7 +27,7 @@ class RoleType extends AbstractType ; } - public function configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver): void { } } diff --git a/src/Nostr/MagazineEventKeys.php b/src/Nostr/MagazineEventKeys.php index ecd2c98..ceb63c8 100644 --- a/src/Nostr/MagazineEventKeys.php +++ b/src/Nostr/MagazineEventKeys.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Nostr; -use swentel\nostr\Key\Key; +use App\Service\NostrKeyHelper; /** * Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL. @@ -52,7 +52,7 @@ final class MagazineEventKeys return strtolower($npub); } try { - $h = (new Key())->convertToHex($npub); + $h = (new NostrKeyHelper())->convertToHex($npub); } catch (\Throwable) { $h = ''; } diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index 3088b45..29013e6 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -2,9 +2,9 @@ namespace App\Security; +use App\Service\NostrKeyHelper; use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; use swentel\nostr\Event\Event; -use swentel\nostr\Key\Key; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -25,6 +25,11 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor */ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { + public function __construct( + private readonly NostrKeyHelper $nostrKeyHelper, + ) { + } + /** * Checks if the request should be handled by this authenticator. * @@ -83,10 +88,8 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut throw new AuthenticationException('Invalid Authorization header'); } - $key = new Key(); - return new SelfValidatingPassport( - new UserBadge($key->convertPublicKeyToBech32($event->getPublicKey())) + new UserBadge($this->nostrKeyHelper->convertPublicKeyToBech32($event->getPublicKey())) ); } diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index 28d84ea..57e49c1 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -10,8 +10,6 @@ use DOMDocument; use DOMElement; use DOMText; use DOMXPath; -use swentel\nostr\Key\Key; - /** * Injects kind-9802 highlight marks into the rendered article body by searching the visible text * in NIP-84 order: event `content` (highlighted span) first, then the `context` tag when set, then @@ -36,6 +34,7 @@ final class ArticleBodyHighlightInjector public function __construct( private readonly HighlightAuthorMetadataProvider $highlightAuthorMetadata, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -338,7 +337,6 @@ final class ArticleBodyHighlightInjector */ private function buildHighlightAuthorsJson(array $group): string { - $key = new Key(); $byNpub = []; foreach ($group as $h) { $eidH = $h->getEventId(); @@ -350,7 +348,7 @@ final class ArticleBodyHighlightInjector continue; } try { - $npub = $key->convertPublicKeyToBech32($pk); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk); } catch (\Throwable) { continue; } diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index 4c140cf..298aa15 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -9,7 +9,6 @@ use App\Nostr\MagazineEventKeys; use App\Repository\EventRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; readonly class CacheService implements HighlightAuthorMetadataProvider { @@ -18,6 +17,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider private EntityManagerInterface $entityManager, private EventRepository $eventRepository, private LoggerInterface $logger, + private NostrKeyHelper $nostrKeyHelper, ) { } @@ -152,7 +152,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider } if (str_starts_with($npub, 'npub1')) { try { - $h = (new Key())->convertToHex($npub); + $h = $this->nostrKeyHelper->convertToHex($npub); } catch (\Throwable) { $h = ''; } diff --git a/src/Service/CommentReplyService.php b/src/Service/CommentReplyService.php index b65de8e..931ca22 100644 --- a/src/Service/CommentReplyService.php +++ b/src/Service/CommentReplyService.php @@ -8,7 +8,6 @@ use App\Entity\User; use App\Enum\KindsEnum; use Psr\Log\LoggerInterface; use swentel\nostr\Event\Event as NostrWireEvent; -use swentel\nostr\Key\Key; /** * Validates NIP-22 kind-1111 comment events from logged-in users and publishes to article relays. @@ -20,6 +19,7 @@ final readonly class CommentReplyService public function __construct( private NostrClient $nostrClient, private LoggerInterface $logger, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -72,8 +72,7 @@ final readonly class CommentReplyService return ['ok' => false, 'error' => 'Event created_at out of range', 'code' => 400]; } - $key = new Key(); - $userHex = $key->convertToHex($user->getNpub() ?? ''); + $userHex = $this->nostrKeyHelper->convertToHex($user->getNpub() ?? ''); if ($userHex === '' || !hash_equals($userHex, $wire->getPublicKey())) { return ['ok' => false, 'error' => 'Pubkey does not match logged-in user', 'code' => 403]; } diff --git a/src/Service/FeaturedAuthorListedRows.php b/src/Service/FeaturedAuthorListedRows.php index 34a00f1..d612858 100644 --- a/src/Service/FeaturedAuthorListedRows.php +++ b/src/Service/FeaturedAuthorListedRows.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Service; use App\Repository\FeaturedAuthorRepository; -use swentel\nostr\Key\Key; /** * NIP-05 / listed featured author rows (same shape as {@see \App\Controller\FeaturedAuthorsController}). @@ -18,6 +17,7 @@ final class FeaturedAuthorListedRows private readonly FeaturedAuthorRepository $featuredAuthorRepository, private readonly CacheService $cacheService, private readonly MagazineContentService $magazineContent, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -33,12 +33,11 @@ final class FeaturedAuthorListedRows return $fromDb; } - $keys = new Key(); $authors = []; $hexes = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(); foreach (\array_slice($hexes, 0, $limit) as $hex) { try { - $npub = $keys->convertPublicKeyToBech32($hex); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex); } catch (\Throwable) { continue; } @@ -53,11 +52,10 @@ final class FeaturedAuthorListedRows */ public function buildListedByLocalPartPage(int $limit, int $offset = 0): array { - $keys = new Key(); $authors = []; foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) { try { - $npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex()); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($fa->getPubkeyHex()); } catch (\Throwable) { continue; } diff --git a/src/Service/FeaturedAuthorSync.php b/src/Service/FeaturedAuthorSync.php index 2369461..834e9d8 100644 --- a/src/Service/FeaturedAuthorSync.php +++ b/src/Service/FeaturedAuthorSync.php @@ -8,7 +8,6 @@ use App\Entity\FeaturedAuthor; use App\Repository\FeaturedAuthorRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; /** * Reconciles {@see FeaturedAuthor} rows with pubkeys found in magazine category `a` tags. @@ -22,6 +21,7 @@ final class FeaturedAuthorSync private readonly CacheService $cacheService, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -43,7 +43,6 @@ final class FeaturedAuthorSync foreach ($this->featuredAuthorRepository->findAll() as $row) { $existingByPubkey[strtolower($row->getPubkeyHex())] = $row; } - $keys = new Key(); $added = 0; $relisted = 0; $unlisted = 0; @@ -54,7 +53,7 @@ final class FeaturedAuthorSync if ($row === null) { $entity = new FeaturedAuthor(); $entity->setPubkeyHex($hex); - $base = $this->deriveBaseLocalPart($keys, $hex); + $base = $this->deriveBaseLocalPart($hex); $entity->setLocalPart($this->allocateUniqueLocalPart($base)); $entity->setIsListed(true); $this->entityManager->persist($entity); @@ -99,20 +98,10 @@ final class FeaturedAuthorSync ]; } - /** - * @deprecated use {@see reconcileListedAuthorsFromMagazineCategories} - */ - public function syncNewAuthorsFromMagazineCategories(): int - { - $st = $this->reconcileListedAuthorsFromMagazineCategories(); - - return $st['added']; - } - - private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string + private function deriveBaseLocalPart(string $pubkeyHex): string { try { - $npub = $keys->convertPublicKeyToBech32($pubkeyHex); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex); } catch (\Throwable) { $npub = null; } diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 25284bb..25683f7 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -28,16 +28,6 @@ final class MagazineContentService ) { } - /** - * @deprecated use {@see getHomeCategoryAIndexTagsFromStoreOnly} (identical; no blocking relay I/O) - * - * @return list> - */ - public function getHomeCategoryIndexTags(): array - { - return $this->getHomeCategoryAIndexTagsFromStoreOnly(); - } - /** * Category `a` tags from the persisted root only (no relay). The store is filled by * `app:prewarm` / cron ({@see MagazineRefresher::refreshFromRelays}), not from HTTP. diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index f99f3a9..25ffd6b 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -151,7 +151,7 @@ final class MagazineRefresher } try { - $this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories(); + $this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories(); } catch (\Throwable $e) { $this->logger->warning('MagazineRefresher: featured author sync failed', [ 'message' => $e->getMessage(), diff --git a/src/Service/Nip05VerificationService.php b/src/Service/Nip05VerificationService.php index 0f4a319..c38adb1 100644 --- a/src/Service/Nip05VerificationService.php +++ b/src/Service/Nip05VerificationService.php @@ -7,11 +7,10 @@ namespace App\Service; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; /** * Fetches /.well-known/nostr.json and checks the listed pubkey (NIP-05). - * Results are stored in the app cache for UI badges and to avoid re-fetching on every request. + * Uses {@see file_get_contents} with an explicit HTTP 200 check, timeout, and npub/hex normalization. */ final readonly class Nip05VerificationService { @@ -22,6 +21,7 @@ final readonly class Nip05VerificationService public function __construct( private CacheItemPoolInterface $appCache, private LoggerInterface $logger, + private NostrKeyHelper $nostrKeyHelper = new NostrKeyHelper(), ) { } @@ -110,7 +110,7 @@ final readonly class Nip05VerificationService return null; } $p = explode('@', $s, 2); - if (($p[0] ?? '') === '' || ($p[1] ?? '') === '' || str_contains($p[1], ' ')) { + if (!isset($p[1]) || $p[0] === '' || $p[1] === '' || str_contains($p[1], ' ')) { return null; } @@ -120,17 +120,41 @@ final readonly class Nip05VerificationService private function checkRemote(string $expectedHex, string $nip05Lower): bool { $parts = explode('@', $nip05Lower, 2); - $local = (string) ($parts[0] ?? ''); - $domain = (string) ($parts[1] ?? ''); - if ($local === '' || $domain === '') { + if (!isset($parts[1]) || $parts[0] === '' || $parts[1] === '') { return false; } - $url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local); - $http_response_header = []; + $local = $parts[0]; + $domain = $parts[1]; + + $data = $this->fetchNostrJson200($domain, $local, $nip05Lower); + if ($data === null) { + return false; + } + if (!isset($data['names']) || !\is_array($data['names'])) { + return false; + } + $val = $this->lookupNameInNames($data['names'], $local); + if (!\is_string($val) || $val === '') { + return false; + } + $rowHex = $this->toHex64($val); + if ($rowHex === null) { + return false; + } + + return hash_equals($expectedHex, $rowHex); + } + + /** + * @return array|null Decoded JSON object on HTTP 200; null on failure or non-200. + */ + private function fetchNostrJson200(string $domain, string $nameLocal, string $nip05LowerForLog): ?array + { + $url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($nameLocal); $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', - 'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json,\r\n", + 'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json\r\n", 'timeout' => self::FETCH_TIMEOUT_SEC, 'ignore_errors' => true, ], @@ -142,40 +166,30 @@ final readonly class Nip05VerificationService $raw = @file_get_contents($url, false, $ctx); if ($raw === false) { $this->logger->info('nip05.verify_fetch_failed', [ - 'nip05' => $nip05Lower, + 'nip05' => $nip05LowerForLog, ]); - return false; + return null; } - $statusLine = (isset($http_response_header) && \is_array($http_response_header)) - ? (string) ($http_response_header[0] ?? '') - : ''; + $statusLine = (string) ($http_response_header[0] ?? ''); if (!preg_match('#\b200\b#', $statusLine)) { $this->logger->info('nip05.verify_not_200', [ - 'nip05' => $nip05Lower, + 'nip05' => $nip05LowerForLog, 'status' => $statusLine, ]); - return false; + return null; } try { $data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException) { - return false; - } - if (!\is_array($data) || !isset($data['names']) || !\is_array($data['names'])) { - return false; - } - $val = $this->lookupNameInNames($data['names'], $local); - if (!\is_string($val) || $val === '') { - return false; + return null; } - $rowHex = $this->toHex64($val); - if ($rowHex === null) { - return false; + if (!\is_array($data)) { + return null; } - return hash_equals($expectedHex, $rowHex); + return $data; } /** @@ -204,8 +218,7 @@ final readonly class Nip05VerificationService } if (str_starts_with($v, 'npub1')) { try { - $k = new Key(); - $hex = $k->convertToHex($v); + $hex = $this->nostrKeyHelper->convertToHex($v); if (64 === \strlen($hex) && ctype_xdigit($hex)) { return strtolower($hex); } diff --git a/src/Service/Nip09DeletionApplier.php b/src/Service/Nip09DeletionApplier.php index 1802f3c..1dd0611 100644 --- a/src/Service/Nip09DeletionApplier.php +++ b/src/Service/Nip09DeletionApplier.php @@ -11,7 +11,6 @@ use App\Repository\ArticleRepository; use App\Repository\EventRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use swentel\nostr\Key\Key; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** @@ -34,6 +33,7 @@ final class Nip09DeletionApplier private readonly EventRepository $eventRepository, private readonly ParameterBagInterface $params, private readonly LoggerInterface $logger, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -342,7 +342,7 @@ final class Nip09DeletionApplier $siteHex = ''; if (str_starts_with($npub, 'npub1')) { try { - $h = (new Key())->convertToHex($npub); + $h = $this->nostrKeyHelper->convertToHex($npub); if (64 === \strlen($h)) { $siteHex = $h; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index c60dd98..9221764 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -15,7 +15,6 @@ use swentel\nostr\Event\Event; use swentel\nostr\Filter\Filter; use swentel\nostr\Message\EventMessage; use swentel\nostr\Message\RequestMessage; -use swentel\nostr\Key\Key; use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\RelaySet; use swentel\nostr\Request\Request; @@ -26,6 +25,12 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; +/** + * Main integration point for swentel/nostr against configured relays: long-form fetch, kind-0 profile + * metadata, article discussion and comment publish relay lists, magazine 30040 / highlight 9802 ingest, + * and related REQ flows. Tuned via `default_relay`, `article_relays`, `profile_relays`, and + * `nostr_relay_request_timeout_sec` (see `config/unfold.yaml`). + */ class NostrClient { /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */ @@ -3328,7 +3333,7 @@ class NostrClient return strtolower($s); } if (str_starts_with($s, 'npub')) { - $hex = (new Key())->convertToHex($s); + $hex = (new NostrKeyHelper())->convertToHex($s); return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null; } diff --git a/src/Service/NostrKeyHelper.php b/src/Service/NostrKeyHelper.php new file mode 100644 index 0000000..8c1f269 --- /dev/null +++ b/src/Service/NostrKeyHelper.php @@ -0,0 +1,41 @@ +key = new Key(); + } + + public function convertToHex(string $key): string + { + return $this->key->convertToHex($key); + } + + public function convertPublicKeyToBech32(string $key): string + { + return $this->key->convertPublicKeyToBech32($key); + } + + public function convertPrivateKeyToBech32(string $key): string + { + return $this->key->convertPrivateKeyToBech32($key); + } + + public function generatePrivateKey(): string + { + return $this->key->generatePrivateKey(); + } +} diff --git a/src/Service/NostrPathHelper.php b/src/Service/NostrPathHelper.php index d61e8e1..f8e9781 100644 --- a/src/Service/NostrPathHelper.php +++ b/src/Service/NostrPathHelper.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Service; use App\Entity\Article; -use swentel\nostr\Key\Key; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** @@ -15,12 +14,13 @@ final class NostrPathHelper { public function __construct( private readonly UrlGeneratorInterface $router, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } public function npubFromPubkeyHex(string $pubkeyHex): string { - return (new Key())->convertPublicKeyToBech32($pubkeyHex); + return $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex); } public function articlePath(Article $article): string diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php index 8463072..14baf42 100644 --- a/src/Service/NostrShareMenuBuilder.php +++ b/src/Service/NostrShareMenuBuilder.php @@ -10,7 +10,6 @@ use App\Entity\Event; use App\Nostr\Nip19Addressable; use App\Nostr\Nip19Codec; use App\Repository\ArticleRepository; -use swentel\nostr\Key\Key; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; @@ -34,8 +33,7 @@ final class NostrShareMenuBuilder if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { return null; } - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($pubkeyHex); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pubkeyHex); $kind = (int) ($event->kind ?? 0); $d = self::dTagFromWireEvent($event); $eventIdHex = strtolower((string) ($event->id ?? '')); @@ -128,6 +126,7 @@ final class NostrShareMenuBuilder public function __construct( private readonly MagazineIndexStore $magazineIndexStore, private readonly ArticleRepository $articleRepository, + private readonly NostrKeyHelper $nostrKeyHelper, private readonly Nip19Codec $nip19, #[Autowire('%npub%')] private readonly string $siteNpub, @@ -140,11 +139,6 @@ final class NostrShareMenuBuilder ) { } - private function nostrKey(): Key - { - return new Key(); - } - /** * Context for the header Nostr menu. Always returns a context on real HTTP requests (never null). * Templates that do not include the header never call this; no need to suppress on XHR / fragments. @@ -180,7 +174,7 @@ final class NostrShareMenuBuilder if ($article === null) { return $this->siteWithRootMenu(); } - if ($this->nostrKey()->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { + if ($this->nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { return $this->siteWithRootMenu(); } @@ -189,7 +183,7 @@ final class NostrShareMenuBuilder private function fromArticle(Article $article): NostrShareMenuContext { - $npub = $this->nostrKey()->convertPublicKeyToBech32((string) $article->getPubkey()); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey()); $kind = (int) ($article->getKind()?->value ?? 30023); $d = (string) ($article->getSlug() ?? ''); if ($d === '') { @@ -280,7 +274,7 @@ final class NostrShareMenuBuilder $rebuilt = $this->nip19->encodeNevent($eventId, $relays, $authorHex, $kind); return new NostrShareMenuContext( - $this->nostrKey()->convertPublicKeyToBech32($authorHex), + $this->nostrKeyHelper->convertPublicKeyToBech32($authorHex), $rebuilt, null, $this->feedJumble($rebuilt), @@ -320,7 +314,7 @@ final class NostrShareMenuBuilder } $kind = (int) $e->getKind(); $d = Nip19Addressable::dTagFromEventEntity($e); - $npub = $this->nostrKey()->convertPublicKeyToBech32($pk); + $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk); if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $neventForRev = $this->nip19->encodeNevent($id, [], $pk, $kind); diff --git a/src/Twig/Components/IndexTabs.php b/src/Twig/Components/IndexTabs.php index a9f6cce..fc8a980 100644 --- a/src/Twig/Components/IndexTabs.php +++ b/src/Twig/Components/IndexTabs.php @@ -36,7 +36,6 @@ class IndexTabs public function mount(EventEntity $index): void { $this->index = $index; - // TODO extract categories from index and feed into tabs foreach ($index->getTags() as $tag) { if (array_key_first($tag) === 'a') { $ref = $tag[1]; diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php index 0497b31..a33df24 100644 --- a/src/Twig/Components/Molecules/UserFromNpub.php +++ b/src/Twig/Components/Molecules/UserFromNpub.php @@ -3,8 +3,8 @@ namespace App\Twig\Components\Molecules; use App\Service\CacheService; +use App\Service\NostrKeyHelper; use App\Util\PubkeyAvatarSvg; -use swentel\nostr\Key\Key; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -18,19 +18,20 @@ final class UserFromNpub public string $fallbackSvg = ''; - public function __construct(private readonly CacheService $cacheService) - { + public function __construct( + private readonly CacheService $cacheService, + private readonly NostrKeyHelper $nostrKeyHelper, + ) { } public function mount(string $ident): void { - $keys = new Key(); if (!str_starts_with($ident, 'npub')) { $this->pubkey = $ident; - $this->npub = $keys->convertPublicKeyToBech32($ident); + $this->npub = $this->nostrKeyHelper->convertPublicKeyToBech32($ident); } else { $this->npub = $ident; - $this->pubkey = $keys->convertToHex($ident); + $this->pubkey = $this->nostrKeyHelper->convertToHex($ident); } $this->user = $this->cacheService->getMetadata($this->npub); diff --git a/src/Twig/MagazineJumbleExtension.php b/src/Twig/MagazineJumbleExtension.php index 2b1fa6b..f9c2ded 100644 --- a/src/Twig/MagazineJumbleExtension.php +++ b/src/Twig/MagazineJumbleExtension.php @@ -6,7 +6,7 @@ namespace App\Twig; use App\Enum\KindsEnum; use App\Nostr\Nip19Addressable; -use swentel\nostr\Key\Key; +use App\Service\NostrKeyHelper; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -23,6 +23,7 @@ final class MagazineJumbleExtension extends AbstractExtension private readonly string $rootMagazineDTag, #[Autowire('%jumble_feed_notes_base%')] private readonly string $jumbleFeedNotesBase, + private readonly NostrKeyHelper $nostrKeyHelper, ) { } @@ -35,9 +36,8 @@ final class MagazineJumbleExtension extends AbstractExtension public function magazineOnJumbleUrl(): string { - $key = new Key(); try { - $pubkeyHex = $key->convertToHex($this->siteNpub); + $pubkeyHex = $this->nostrKeyHelper->convertToHex($this->siteNpub); } catch (\Throwable) { return '#'; } diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php index b28e518..8e3ac4d 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php @@ -14,7 +14,7 @@ class NostrEventRenderer implements NodeRendererInterface private readonly Nip19Codec $nip19, ) { } - public function render(Node $node, ChildNodeRendererInterface $childRenderer) + public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable|string|null { if (!($node instanceof NostrSchemeData)) { throw new \InvalidArgumentException('Incompatible inline node type: '.get_class($node)); @@ -25,7 +25,7 @@ class NostrEventRenderer implements NodeRendererInterface return $this->renderPreviewOrFallback($node, $type); } - return false; + return null; } private function renderPreviewOrFallback(NostrSchemeData $node, string $type): HtmlElement diff --git a/symfony.lock b/symfony.lock index 35d2c69..223446d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -47,6 +47,15 @@ "config/packages/nyholm_psr7.yaml" ] }, + "phpstan/phpstan": { + "version": "2.1", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" + } + }, "phpunit/phpunit": { "version": "9.6", "recipe": { diff --git a/tests/Security/NostrAuthenticatorTest.php b/tests/Security/NostrAuthenticatorTest.php index 9fc1d4d..219af6f 100644 --- a/tests/Security/NostrAuthenticatorTest.php +++ b/tests/Security/NostrAuthenticatorTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Security; use App\Security\NostrAuthenticator; +use App\Service\NostrKeyHelper; use PHPUnit\Framework\TestCase; use swentel\nostr\Event\Event; use swentel\nostr\Key\Key; @@ -24,7 +25,7 @@ class NostrAuthenticatorTest extends TestCase $token = 'Nostr '.$this->signedAuthEventBase64($nsec); $request = Request::create('/login', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => $token]); - $out = (new NostrAuthenticator())->authenticate($request); + $out = (new NostrAuthenticator(new NostrKeyHelper()))->authenticate($request); $this->assertInstanceOf(SelfValidatingPassport::class, $out); } @@ -35,7 +36,7 @@ class NostrAuthenticatorTest extends TestCase $request = Request::create('/login', 'GET', [], [], [], [ 'HTTP_AUTHORIZATION' => 'InvalidHeader', ]); - (new NostrAuthenticator())->authenticate($request); + (new NostrAuthenticator(new NostrKeyHelper()))->authenticate($request); } public function testExpiredEventThrows(): void @@ -46,7 +47,7 @@ class NostrAuthenticatorTest extends TestCase $request = Request::create('/login', 'GET', [], [], [], [ 'HTTP_AUTHORIZATION' => $expiredToken, ]); - (new NostrAuthenticator())->authenticate($request); + (new NostrAuthenticator(new NostrKeyHelper()))->authenticate($request); } private function signedAuthEventBase64(string $nsec): string diff --git a/tests/Service/ArticleBodyHighlightInjectorTest.php b/tests/Service/ArticleBodyHighlightInjectorTest.php index 410a28b..0dfcc2b 100644 --- a/tests/Service/ArticleBodyHighlightInjectorTest.php +++ b/tests/Service/ArticleBodyHighlightInjectorTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Service; use App\Entity\ArticleHighlight; use App\Service\ArticleBodyHighlightInjector; use App\Service\HighlightAuthorMetadataProvider; +use App\Service\NostrKeyHelper; use PHPUnit\Framework\TestCase; /** @@ -141,7 +142,7 @@ final class ArticleBodyHighlightInjectorTest extends TestCase ] ); - return new ArticleBodyHighlightInjector($meta); + return new ArticleBodyHighlightInjector($meta, new NostrKeyHelper()); } /** diff --git a/tests/Service/ArticleHighlightCommonMarkPipelineTest.php b/tests/Service/ArticleHighlightCommonMarkPipelineTest.php index 2baf943..74e7134 100644 --- a/tests/Service/ArticleHighlightCommonMarkPipelineTest.php +++ b/tests/Service/ArticleHighlightCommonMarkPipelineTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Service; use App\Entity\ArticleHighlight; use App\Service\ArticleBodyHighlightInjector; use App\Service\HighlightAuthorMetadataProvider; +use App\Service\NostrKeyHelper; use App\Util\CommonMark\Converter; use League\CommonMark\Exception\CommonMarkException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -107,7 +108,7 @@ final class ArticleHighlightCommonMarkPipelineTest extends KernelTestCase (object) ['display_name' => 'Test', 'name' => 'Test', 'picture' => ''], ); - return new ArticleBodyHighlightInjector($meta); + return new ArticleBodyHighlightInjector($meta, new NostrKeyHelper()); } private function makeHighlight( diff --git a/tests/phpstan-doctrine-object-manager.php b/tests/phpstan-doctrine-object-manager.php new file mode 100644 index 0000000..eb1c93e --- /dev/null +++ b/tests/phpstan-doctrine-object-manager.php @@ -0,0 +1,29 @@ +bootEnv($root.'/.env'); +} + +// Defaults when .env is missing (e.g. CI before env is wired). +if (!isset($_ENV['APP_ENV'], $_SERVER['APP_ENV'])) { + $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = 'dev'; +} +if (!isset($_ENV['APP_SECRET']) && !isset($_SERVER['APP_SECRET'])) { + $_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = 'phpstan_bootstrap_not_for_prod'; +} +if (!isset($_SERVER['DATABASE_URL']) && !isset($_ENV['DATABASE_URL'])) { + $_SERVER['DATABASE_URL'] = $_ENV['DATABASE_URL'] = 'sqlite:////tmp/unfold_phpstan_meta.sqlite'; +} + +$kernel = new Kernel('dev', true); +$kernel->boot(); + +return $kernel->getContainer()->get('doctrine')->getManager();