diff --git a/.env.dist b/.env.dist index 406b333..c6125f9 100644 --- a/.env.dist +++ b/.env.dist @@ -27,7 +27,7 @@ HTTPS_PORT=9443 # SERVER_NAME=:80 # If MYSQL_* changed after the DB volume exists: docker compose down -v (wipes data), then up. MYSQL_DATABASE=unfold_db -MYSQL_VERSION=8.0 +MYSQL_VERSION=8.0.36 MYSQL_CHARSET=utf8mb4 MYSQL_USER=unfold_user MYSQL_PASSWORD=password diff --git a/compose.hub.yaml b/compose.hub.yaml index 0523acb..7b54860 100644 --- a/compose.hub.yaml +++ b/compose.hub.yaml @@ -33,7 +33,7 @@ services: APP_SECRET: ${APP_SECRET} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} SERVER_NAME: ${SERVER_NAME:-:80} - DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} volumes: - caddy_data:/data - caddy_config:/config @@ -83,7 +83,7 @@ services: APP_SECRET: ${APP_SECRET} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} SERVER_NAME: ${SERVER_NAME:-:80} - DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} PREWARM_FLAGS: ${PREWARM_FLAGS:-} depends_on: database: @@ -92,7 +92,7 @@ services: condition: service_started database: - image: mysql:${MYSQL_VERSION:-8.0} + image: mysql:${MYSQL_VERSION:-8.0.36} restart: unless-stopped environment: MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} diff --git a/compose.yaml b/compose.yaml index 6e2c1f6..c595a9d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: # Caddy site address: :80 accepts any Host (needed when the app is reached via localhost:HTTP_PORT). SERVER_NAME: ${SERVER_NAME:-:80} # Defaults match .env.dist so a first boot without a .env file creates the same DB user as copying .env.dist later. - DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} volumes: - caddy_data:/data - caddy_config:/config @@ -30,7 +30,7 @@ services: # you can comment out or remove this block. Make sure to update the DATABASE_URL in the php service accordingly. # --- database: - image: mysql:${MYSQL_VERSION:-8.0} + image: mysql:${MYSQL_VERSION:-8.0.36} environment: MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} MYSQL_USER: ${MYSQL_USER:-unfold_user} diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f225323..6219087 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -2,9 +2,9 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' driver: pdo_mysql - # Pin version so DBAL 4 does not treat the server as "MySQL < 8" and emit deprecations - # on every request (see AbstractMySQLDriver + serverVersion auto-detection). - server_version: '8.0' + # DBAL 4 requires a full x.y.z version string to unambiguously identify MySQL 8.0.x vs 8.4+. + # Using '8.0' (without patch) triggers a deprecation notice on every request. + server_version: '8.0.36' charset: utf8mb4 default_table_options: charset: utf8mb4 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d94cdbb..6c0fb55 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,17 +1,5 @@ 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 @@ -24,162 +12,6 @@ parameters: 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\\>, nip30_custom_emojis\: 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 @@ -193,280 +25,10 @@ parameters: 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: '#^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 + message: '#^PHPDoc tag @param references unknown parameter\: \$rawTags$#' + identifier: parameter.notFound count: 1 - path: src/Service/MagazineContentService.php + path: src/Nostr/Nip10Kind1ArticleReplyTags.php - message: '#^Cannot call method __invoke\(\) on callable\.$#' @@ -474,194 +36,26 @@ parameters: 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: 1 - path: src/Service/NostrClient.php - - - - message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 5 - path: src/Service/NostrClient.php - - - - message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 6 - 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: '#^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: 1 - 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 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/src/Repository/ArticleHighlightRepository.php b/src/Repository/ArticleHighlightRepository.php index 69f1a97..2b4c4cb 100644 --- a/src/Repository/ArticleHighlightRepository.php +++ b/src/Repository/ArticleHighlightRepository.php @@ -22,7 +22,9 @@ class ArticleHighlightRepository extends ServiceEntityRepository /** * Newest highlights across published/archived long-form, for the home aside. - * The home page caps the query (e.g. 100); the template scroller shows roughly ten at a time. + * At most one highlight is returned per article so a single heavily-highlighted + * article cannot flood the sidebar. The most recent highlight for each article + * wins (ORDER BY eventCreatedAt DESC). * * @return list */ @@ -32,18 +34,42 @@ class ArticleHighlightRepository extends ServiceEntityRepository return []; } + // Fetch a larger pool so that after per-article deduplication we still + // have enough items to fill the sidebar. Cap the raw fetch at 2 000 to + // avoid unbounded memory use on busy sites. + $fetchLimit = min($limit * 20, 2000); + $qb = $this->createQueryBuilder('h') ->innerJoin('h.article', 'a') ->where('a.eventStatus IN (:st)') ->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) ->orderBy('h.eventCreatedAt', 'DESC') ->addOrderBy('h.id', 'DESC') - ->setMaxResults($limit); + ->setMaxResults($fetchLimit); /** @var list $rows */ $rows = $qb->getQuery()->getResult(); - return $rows; + // Keep only the first (= most recent) highlight per article. + $seen = []; + $out = []; + foreach ($rows as $h) { + $article = $h->getArticle(); + $articleId = $article?->getId(); + if ($articleId === null) { + continue; + } + if (isset($seen[$articleId])) { + continue; + } + $seen[$articleId] = true; + $out[] = $h; + if (\count($out) >= $limit) { + break; + } + } + + return $out; } /** diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index 6a80129..bafe667 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -29,7 +29,6 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf private EventRepository $eventRepository, private LoggerInterface $logger, private NostrKeyHelper $nostrKeyHelper, - private NostrNip65RelayUrls $nip65RelayUrls, private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder, ) { } @@ -223,26 +222,6 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf return $n; } - public function getRelays($npub) - { - $authorHex = $this->npubToAuthorHex64($npub); - if ($authorHex === null) { - return []; - } - $key = MagazineEventKeys::relayList10002($authorHex); - $row = $this->eventRepository->findOneByCoreRowKey($key); - if ($row !== null) { - return self::relayWssListFromNip65Tags($row->getTags()); - } - $wire = $this->nostrClient->getNpubRelayList10002Wire($npub); - if ($wire === null) { - return []; - } - $this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire); - - return $this->nip65RelayUrls->wssListFromKind10002Wire($wire); - } - /** * @return list> */ @@ -432,24 +411,4 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf ]; } - /** - * @param list>|array $tags - * @return list - */ - private static function relayWssListFromNip65Tags(array $tags): array - { - $relays = []; - foreach ($tags as $tag) { - if (!\is_array($tag) || !isset($tag[0], $tag[1])) { - continue; - } - if ((string) $tag[0] === 'r') { - $relays[] = (string) $tag[1]; - } - } - - return array_filter(array_unique($relays), static function (string $relay) { - return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); - }); - } } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 510305e..f2d6dae 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -17,7 +17,6 @@ use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\RelaySet; use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; /** * Main integration point for swentel/nostr against configured relays: long-form fetch, kind-0 profile @@ -58,7 +57,6 @@ class NostrClient private readonly EntityManagerInterface $entityManager, private readonly ManagerRegistry $managerRegistry, private readonly ArticleFactory $articleFactory, - private readonly TokenStorageInterface $tokenStorage, private readonly LoggerInterface $logger, private readonly string $projectDir, private readonly NostrRelayRequestFactory $relayRequestFactory, @@ -140,66 +138,6 @@ class NostrClient return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix(); } - /** - * Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys). - * - * @param list $authorPubkeyHex - * @return array Newest kind-0 JSON per pubkey, keyed by hex - */ - public function fetchKind0MetadataForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array - { - $authorPubkeyHex = \array_values(\array_unique(\array_filter( - $authorPubkeyHex, - static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h), - ))); - if ($authorPubkeyHex === []) { - return []; - } - $authorsPerRequest = max(1, min(200, $authorsPerRequest)); - $byPub = []; - $relaysTried = $this->relayListFactory->getProfileMetadataQueryRelayUrlList(); - $relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); - $relaySet = $this->relayListFactory->getRelaySetForProfileMetadataFetch(); - $chunks = array_chunk($authorPubkeyHex, $authorsPerRequest); - foreach ($chunks as $i => $chunk) { - $t0 = microtime(true); - $request = $this->nostrRelayQuery->createNostrRequest( - defaultRelaySet: $this->defaultRelaySet, - kinds: [KindsEnum::METADATA], - filters: ['authors' => $chunk], - relaySet: $relaySet - ); - $events = $this->nostrRelayQuery->processResponse( - $request->send(), - static fn ($ev) => $ev, - ); - $this->logger->info('nostr.metadata.batch_chunk', [ - 'chunk' => 1 + $i, - 'of' => \count($chunks), - 'authors' => \count($chunk), - 'events' => \count($events), - 'relays' => $relaysTriedStr, - 'ms' => (int) round((microtime(true) - $t0) * 1000), - ]); - foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) { - if (!\is_object($ev) || !isset($ev->content)) { - continue; - } - $pk = \substr($addr, 2); - try { - $data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - continue; - } - if (\is_object($data)) { - $byPub[$pk] = $data; - } - } - } - - return $byPub; - } - /** * Batched kind-0 fetch: one REQ per chunk; returns latest wire event per author (for DB persistence). * @@ -381,47 +319,6 @@ class NostrClient return array_values($byId); } - /** - * @throws \Exception - */ - public function getNpubMetadata($npub): \stdClass - { - $authorHex = $this->wireMerge->authorIdentToHexLower($npub); - if ($authorHex === null) { - throw new \Exception('Invalid npub for metadata: '.$npub); - } - $relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList()); - $relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); - $relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried); - $this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]); - $request = $this->nostrRelayQuery->createNostrRequest( - defaultRelaySet: $this->defaultRelaySet, - kinds: [KindsEnum::METADATA], - filters: ['authors' => [$authorHex]], - relaySet: $relaySet - ); - - $events = $this->nostrRelayQuery->processResponse( - $request->send(), - function ($received) { - $this->logger->debug('nostr.metadata.relay_event', ['event' => $received]); - - return $received; - }, - ); - - if (empty($events)) { - throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')'); - } - $byAddr = $this->wireMerge->mergeKind0EventsByReplaceableAddress($events); - $key = '0:'.$authorHex; - if (!isset($byAddr[$key])) { - throw new \Exception('No kind-0 metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')'); - } - - return $byAddr[$key]; - } - /** * NIP-A3 kind 10133: payment target events; NIP kind-range 10_000–19_999 is replaceable by * (kind, pubkey), so multi-relay results are merged to the live revision per @@ -465,49 +362,6 @@ class NostrClient return $this->wireMerge->mergeNip33ParameterizedWireEvents($events); } - public function getNpubLongForm($npub): void - { - $authorHex = $this->wireMerge->authorIdentToHexLower($npub); - if ($authorHex === null) { - $this->logger->warning('nostr.longform_by_author.invalid_npub', ['npub' => $npub]); - - return; - } - $subscription = new Subscription(); - $subscriptionId = $subscription->setId(); - $filter = new Filter(); - $filter->setKinds([KindsEnum::LONGFORM]); - $filter->setAuthors([$authorHex]); - $filter->setSince(strtotime('-6 months')); // too much? - $requestMessage = new RequestMessage($subscriptionId, [$filter]); - - // if user is logged in, use their settings - /* @var $user */ - $user = $this->tokenStorage->getToken()?->getUser(); - $relays = $this->defaultRelaySet; - if ($user && $user->getRelays()) { - $relays = new RelaySet(); - foreach ($user->getRelays() as $relayArr) { - if ($relayArr[2] == 'write') { - $relays->addRelay(new Relay($relayArr[1])); - } - } - } - - $request = $this->relayRequestFactory->createTimedRequest($relays, $requestMessage); - - $wrappers = $this->nostrRelayQuery->processResponse($request->send(), function (object $event) { - $w = new \stdClass(); - $w->event = $event; - - return $w; - }); - if ($wrappers !== []) { - $this->saveLongFormContent($wrappers); - } - // TODO handle relays that require auth - } - public function publishEvent(Event $event, array $relays): array { $eventMessage = new EventMessage($event); @@ -604,13 +458,20 @@ class NostrClient } $relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); + $authorHex = $this->wireMerge->authorIdentToHexLower($author); + if ($authorHex === null) { + $this->logger->warning('nostr.longform_naddr.invalid_author', ['author' => $author]); + + return; + } + try { // Create request using the helper method for forest relay set $request = $this->nostrRelayQuery->createNostrRequest( defaultRelaySet: $this->defaultRelaySet, kinds: [$kind], filters: [ - 'authors' => [$author], + 'authors' => [$authorHex], 'tag' => ['#d', [$slug]] ], relaySet: $authorRelaySet @@ -623,9 +484,8 @@ class NostrClient if (!empty($events)) { $kindI = (int) $kind; - $authorH = $this->wireMerge->authorIdentToHexLower($author); - $event = $this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null - ? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug) + $event = $this->wireMerge->isNip33ParameterizedKind($kindI) + ? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorHex, (string) $slug) : null; if ($event === null) { $event = $events[0]; @@ -780,9 +640,13 @@ class NostrClient if (empty($pubkey) || empty($identifier)) { return null; } + $authorHex = $this->wireMerge->authorIdentToHexLower($pubkey); + if ($authorHex === null) { + return null; + } // Try author's relays first - $authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey) : $relays; + $authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($authorHex) : $relays; $relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays); // Create request using the helper method @@ -790,7 +654,7 @@ class NostrClient defaultRelaySet: $this->defaultRelaySet, kinds: [$kind], filters: [ - 'authors' => [$pubkey], + 'authors' => [$authorHex], 'tag' => ['#d', [$identifier]] ], relaySet: $relaySet @@ -810,14 +674,12 @@ class NostrClient defaultRelaySet: $this->defaultRelaySet, kinds: [$kind], filters: [ - 'authors' => [$pubkey], + 'authors' => [$authorHex], 'tag' => ['#d', [$identifier]] ] ); - $events = $this->nostrRelayQuery->processResponse($request->send(), function($event) { - return $event; - }); + $events = $this->nostrRelayQuery->processResponse($request->send(), static fn (object $e) => $e); return !empty($events) ? $events[0] : null; } @@ -1224,52 +1086,6 @@ class NostrClient }); } - /** - * @throws \Exception - */ - public function getLongFormContentForPubkey(string $ident): array - { - $authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($ident); - $base = $this->relayListFactory->getConfiguredArticleRelayUrlList(); - $merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base; - $seen = []; - $deduped = []; - foreach ($merged as $url) { - if (!\is_string($url) || $url === '' || isset($seen[$url])) { - continue; - } - $seen[$url] = true; - $deduped[] = $url; - } - $capped = $this->relayListFactory->capSequentialRelaysForProfileFetches($deduped); - $relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($capped); - - // Create request using the helper method - $request = $this->nostrRelayQuery->createNostrRequest( - defaultRelaySet: $this->defaultRelaySet, - kinds: [KindsEnum::LONGFORM], - filters: [ - 'authors' => [$ident], - 'limit' => 10 - ], - relaySet: $relaySet - ); - - $events = $this->nostrRelayQuery->processResponse( - $request->send(), - static fn (object $event) => $event, - ); - foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) { - if (!\is_object($event)) { - continue; - } - $article = $this->articleFactory->createFromLongFormContentEvent($event); - $this->saveEachArticleToTheDatabase($article); - } - - return []; - } - public function getArticles(array $slugs): array { $articles = []; diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php index 2f4bad8..072cb7d 100644 --- a/src/Util/CommonMark/Converter.php +++ b/src/Util/CommonMark/Converter.php @@ -4,6 +4,7 @@ namespace App\Util\CommonMark; use App\Nostr\Nip19Codec; use App\Service\CacheService; +use App\Util\NpubBech32Extractor; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; use League\CommonMark\Environment\Environment; @@ -36,6 +37,8 @@ readonly class Converter */ public function convertToHTML(string $markdown): string { + $this->cacheService->prefetchMetadataForNpubs(NpubBech32Extractor::extractFromText($markdown)); + // Check if the article has more than three headings // Match all headings (from level 1 to 6) preg_match_all('/^#+\s.*$/m', $markdown, $matches); diff --git a/src/Util/NpubBech32Extractor.php b/src/Util/NpubBech32Extractor.php new file mode 100644 index 0000000..3bec479 --- /dev/null +++ b/src/Util/NpubBech32Extractor.php @@ -0,0 +1,26 @@ + + */ + public static function extractFromText(string $text): array + { + if ($text === '') { + return []; + } + if (preg_match_all('/npub1[0-9a-z]+/i', $text, $matches) !== 1) { + return []; + } + + return array_values(array_unique($matches[0])); + } +}