Browse Source

refactor and code clean-up

imwald
Silberengel 18 hours ago
parent
commit
2276fb5d2b
  1. 7
      composer.json
  2. 205
      composer.lock
  3. 709
      phpstan-baseline.neon
  4. 17
      phpstan.neon.dist
  5. 8
      src/Command/ArticleHighlightsAuditCommand.php
  6. 10
      src/Command/PrewarmCommand.php
  7. 37
      src/Controller/ArticleController.php
  8. 11
      src/Controller/AuthorController.php
  9. 6
      src/Controller/EventController.php
  10. 4
      src/Form/RoleType.php
  11. 4
      src/Nostr/MagazineEventKeys.php
  12. 11
      src/Security/NostrAuthenticator.php
  13. 6
      src/Service/ArticleBodyHighlightInjector.php
  14. 4
      src/Service/CacheService.php
  15. 5
      src/Service/CommentReplyService.php
  16. 8
      src/Service/FeaturedAuthorListedRows.php
  17. 19
      src/Service/FeaturedAuthorSync.php
  18. 10
      src/Service/MagazineContentService.php
  19. 2
      src/Service/MagazineRefresher.php
  20. 73
      src/Service/Nip05VerificationService.php
  21. 4
      src/Service/Nip09DeletionApplier.php
  22. 9
      src/Service/NostrClient.php
  23. 41
      src/Service/NostrKeyHelper.php
  24. 4
      src/Service/NostrPathHelper.php
  25. 18
      src/Service/NostrShareMenuBuilder.php
  26. 1
      src/Twig/Components/IndexTabs.php
  27. 13
      src/Twig/Components/Molecules/UserFromNpub.php
  28. 6
      src/Twig/MagazineJumbleExtension.php
  29. 4
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  30. 9
      symfony.lock
  31. 7
      tests/Security/NostrAuthenticatorTest.php
  32. 3
      tests/Service/ArticleBodyHighlightInjectorTest.php
  33. 3
      tests/Service/ArticleHighlightCommonMarkPipelineTest.php
  34. 29
      tests/phpstan-doctrine-object-manager.php

7
composer.json

@ -94,6 +94,10 @@ @@ -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 @@ @@ -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.*",

205
composer.lock generated

@ -4,7 +4,7 @@ @@ -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 @@ @@ -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",

709
phpstan-baseline.neon

@ -0,0 +1,709 @@ @@ -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\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, 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\<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 ''event_id'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<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 ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>\} 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\<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 ''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\<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 ''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\<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 ''slug'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<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 ''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\<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 ''totals'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, 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\<non\-falsy\-string\> 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\<mixed\>\:\: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\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>, comment_reply_context\: array\{can_publish\: bool, coordinate\: string, article_event_id\: string\|null, parent_kind\: int, rows\: array\<int, array\<string, mixed\>\>, 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\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>\} 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\<string\> 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\<string, mixed\>, 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\<string, mixed\>, 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\<int, string\> 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\<App\\Entity\\Article\>, 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\<mixed\> 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\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? does not exist\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, 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\<int, object\>, quotes\: array\<int, object\>\} 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\<int, object\>, quotes\: array\<int, object\>, 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\<int, object\>, quotes\: array\<int, object\>\} 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\<string\> 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\<string\> 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\<string\>\) 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\<string\> 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\<array\{entries\: list\<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 ''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\<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 ''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\<string\> 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\<string, mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_array\(\) with list\<string\> 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\<non\-empty\-string\>\} 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\<non\-empty\-string\>\} 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\<non\-empty\-string\>\} 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\<non\-empty\-string\>\} 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\<string\>\) 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\<string\> 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\<non\-empty\-string\> 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\<string\> 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\<int, mixed\>\|list\<mixed\> 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\<string\> 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\<string\>\) 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\<string\> 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\<object\>\.$#'
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\<string\> 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

17
phpstan.neon.dist

@ -0,0 +1,17 @@ @@ -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

8
src/Command/ArticleHighlightsAuditCommand.php

@ -9,8 +9,8 @@ use App\Repository\ArticleHighlightRepository; @@ -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 @@ -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 @@ -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;

10
src/Command/PrewarmCommand.php

@ -16,9 +16,9 @@ use App\Service\HighlightSyncService; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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);

37
src/Controller/ArticleController.php

@ -11,6 +11,7 @@ use App\Nostr\Nip22CommentTags; @@ -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; @@ -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 @@ -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 @@ -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 @@ -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 @@ -316,7 +316,8 @@ class ArticleController extends AbstractController
$converter,
$commentThreadLoader,
$articleHighlightRepository,
$articleBodyHighlightInjector
$articleBodyHighlightInjector,
$nostrKeyHelper
);
}
@ -332,13 +333,13 @@ class ArticleController extends AbstractController @@ -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 @@ -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 @@ -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 @@ -464,8 +466,7 @@ class ArticleController extends AbstractController
if (!\is_object($hint) || !isset($hint->pubkey)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} 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 @@ -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 @@ -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 @@ -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,

11
src/Controller/AuthorController.php

@ -9,10 +9,10 @@ use App\Repository\FeaturedAuthorRepository; @@ -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 @@ -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 @@ -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]);
}

6
src/Controller/EventController.php

@ -9,9 +9,9 @@ use App\Service\NostrClient; @@ -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 @@ -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 @@ -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);
}

4
src/Form/RoleType.php

@ -14,7 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; @@ -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 @@ -27,7 +27,7 @@ class RoleType extends AbstractType
;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
}
}

4
src/Nostr/MagazineEventKeys.php

@ -4,7 +4,7 @@ declare(strict_types=1); @@ -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 @@ -52,7 +52,7 @@ final class MagazineEventKeys
return strtolower($npub);
}
try {
$h = (new Key())->convertToHex($npub);
$h = (new NostrKeyHelper())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}

11
src/Security/NostrAuthenticator.php

@ -2,9 +2,9 @@ @@ -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 @@ -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 @@ -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()))
);
}

6
src/Service/ArticleBodyHighlightInjector.php

@ -10,8 +10,6 @@ use DOMDocument; @@ -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 @@ -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 @@ -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 @@ -350,7 +348,7 @@ final class ArticleBodyHighlightInjector
continue;
}
try {
$npub = $key->convertPublicKeyToBech32($pk);
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk);
} catch (\Throwable) {
continue;
}

4
src/Service/CacheService.php

@ -9,7 +9,6 @@ use App\Nostr\MagazineEventKeys; @@ -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 @@ -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 @@ -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 = '';
}

5
src/Service/CommentReplyService.php

@ -8,7 +8,6 @@ use App\Entity\User; @@ -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 @@ -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 @@ -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];
}

8
src/Service/FeaturedAuthorListedRows.php

@ -5,7 +5,6 @@ declare(strict_types=1); @@ -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 @@ -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 @@ -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 @@ -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;
}

19
src/Service/FeaturedAuthorSync.php

@ -8,7 +8,6 @@ use App\Entity\FeaturedAuthor; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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;
}

10
src/Service/MagazineContentService.php

@ -28,16 +28,6 @@ final class MagazineContentService @@ -28,16 +28,6 @@ final class MagazineContentService
) {
}
/**
* @deprecated use {@see getHomeCategoryAIndexTagsFromStoreOnly} (identical; no blocking relay I/O)
*
* @return list<array<int, string>>
*/
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.

2
src/Service/MagazineRefresher.php

@ -151,7 +151,7 @@ final class MagazineRefresher @@ -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(),

73
src/Service/Nip05VerificationService.php

@ -7,11 +7,10 @@ namespace App\Service; @@ -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 <domain>/.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 @@ -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 @@ -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 @@ -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<string, mixed>|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 @@ -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 @@ -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);
}

4
src/Service/Nip09DeletionApplier.php

@ -11,7 +11,6 @@ use App\Repository\ArticleRepository; @@ -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 @@ -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 @@ -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;
}

9
src/Service/NostrClient.php

@ -15,7 +15,6 @@ use swentel\nostr\Event\Event; @@ -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 @@ -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 @@ -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;
}

41
src/Service/NostrKeyHelper.php

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use swentel\nostr\Key\Key;
/**
* Shared {@link Key} wrapper for npub/nsec/hex conversions. Prefer injecting this service instead of
* instantiating {@see Key} in controllers, commands, and services.
*/
final readonly class NostrKeyHelper
{
private Key $key;
public function __construct()
{
$this->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();
}
}

4
src/Service/NostrPathHelper.php

@ -5,7 +5,6 @@ declare(strict_types=1); @@ -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 @@ -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

18
src/Service/NostrShareMenuBuilder.php

@ -10,7 +10,6 @@ use App\Entity\Event; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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);

1
src/Twig/Components/IndexTabs.php

@ -36,7 +36,6 @@ class IndexTabs @@ -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];

13
src/Twig/Components/Molecules/UserFromNpub.php

@ -3,8 +3,8 @@ @@ -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 @@ -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);

6
src/Twig/MagazineJumbleExtension.php

@ -6,7 +6,7 @@ namespace App\Twig; @@ -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 @@ -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 @@ -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 '#';
}

4
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -14,7 +14,7 @@ class NostrEventRenderer implements NodeRendererInterface @@ -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 @@ -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

9
symfony.lock

@ -47,6 +47,15 @@ @@ -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": {

7
tests/Security/NostrAuthenticatorTest.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -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 @@ -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 @@ -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 @@ -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

3
tests/Service/ArticleBodyHighlightInjectorTest.php

@ -7,6 +7,7 @@ namespace App\Tests\Service; @@ -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 @@ -141,7 +142,7 @@ final class ArticleBodyHighlightInjectorTest extends TestCase
]
);
return new ArticleBodyHighlightInjector($meta);
return new ArticleBodyHighlightInjector($meta, new NostrKeyHelper());
}
/**

3
tests/Service/ArticleHighlightCommonMarkPipelineTest.php

@ -7,6 +7,7 @@ namespace App\Tests\Service; @@ -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 @@ -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(

29
tests/phpstan-doctrine-object-manager.php

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
$root = dirname(__DIR__);
if (is_file($root.'/.env')) {
(new Dotenv())->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();
Loading…
Cancel
Save