Browse Source

refactor

limit highlights on landing page to 1 per article
prevent MySQL error on startup
gitcitadel
Silberengel 2 weeks ago
parent
commit
e42aa8eb0b
  1. 2
      .env.dist
  2. 6
      compose.hub.yaml
  3. 4
      compose.yaml
  4. 6
      config/packages/doctrine.yaml
  5. 612
      phpstan-baseline.neon
  6. 32
      src/Repository/ArticleHighlightRepository.php
  7. 41
      src/Service/CacheService.php
  8. 220
      src/Service/NostrClient.php
  9. 3
      src/Util/CommonMark/Converter.php
  10. 26
      src/Util/NpubBech32Extractor.php

2
.env.dist

@ -27,7 +27,7 @@ HTTPS_PORT=9443
# SERVER_NAME=:80 # SERVER_NAME=:80
# If MYSQL_* changed after the DB volume exists: docker compose down -v (wipes data), then up. # If MYSQL_* changed after the DB volume exists: docker compose down -v (wipes data), then up.
MYSQL_DATABASE=unfold_db MYSQL_DATABASE=unfold_db
MYSQL_VERSION=8.0 MYSQL_VERSION=8.0.36
MYSQL_CHARSET=utf8mb4 MYSQL_CHARSET=utf8mb4
MYSQL_USER=unfold_user MYSQL_USER=unfold_user
MYSQL_PASSWORD=password MYSQL_PASSWORD=password

6
compose.hub.yaml

@ -33,7 +33,7 @@ services:
APP_SECRET: ${APP_SECRET} APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8}
SERVER_NAME: ${SERVER_NAME:-:80} 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: volumes:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
@ -83,7 +83,7 @@ services:
APP_SECRET: ${APP_SECRET} APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8}
SERVER_NAME: ${SERVER_NAME:-:80} 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:-} PREWARM_FLAGS: ${PREWARM_FLAGS:-}
depends_on: depends_on:
database: database:
@ -92,7 +92,7 @@ services:
condition: service_started condition: service_started
database: database:
image: mysql:${MYSQL_VERSION:-8.0} image: mysql:${MYSQL_VERSION:-8.0.36}
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db}

4
compose.yaml

@ -17,7 +17,7 @@ services:
# Caddy site address: :80 accepts any Host (needed when the app is reached via localhost:HTTP_PORT). # Caddy site address: :80 accepts any Host (needed when the app is reached via localhost:HTTP_PORT).
SERVER_NAME: ${SERVER_NAME:-:80} 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. # 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: volumes:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - 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. # you can comment out or remove this block. Make sure to update the DATABASE_URL in the php service accordingly.
# --- # ---
database: database:
image: mysql:${MYSQL_VERSION:-8.0} image: mysql:${MYSQL_VERSION:-8.0.36}
environment: environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db}
MYSQL_USER: ${MYSQL_USER:-unfold_user} MYSQL_USER: ${MYSQL_USER:-unfold_user}

6
config/packages/doctrine.yaml

@ -2,9 +2,9 @@ doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
driver: pdo_mysql driver: pdo_mysql
# Pin version so DBAL 4 does not treat the server as "MySQL < 8" and emit deprecations # DBAL 4 requires a full x.y.z version string to unambiguously identify MySQL 8.0.x vs 8.4+.
# on every request (see AbstractMySQLDriver + serverVersion auto-detection). # Using '8.0' (without patch) triggers a deprecation notice on every request.
server_version: '8.0' server_version: '8.0.36'
charset: utf8mb4 charset: utf8mb4
default_table_options: default_table_options:
charset: utf8mb4 charset: utf8mb4

612
phpstan-baseline.neon

@ -1,17 +1,5 @@
parameters: parameters:
ignoreErrors: 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\(\)\.$#' message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#'
identifier: method.notFound identifier: method.notFound
@ -24,162 +12,6 @@ parameters:
count: 1 count: 1
path: src/Command/NostrEventFromYamlDefinitionCommand.php 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\>\>, nip30_custom_emojis\: list\<array\{shortcode\: string, url\: string, set\?\: 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\(\)\.$#' message: '#^Call to an undefined method Symfony\\Component\\Form\\FormInterface\<mixed\>\:\:getClickedButton\(\)\.$#'
identifier: method.notFound identifier: method.notFound
@ -193,280 +25,10 @@ parameters:
path: src/Controller/ArticleController.php path: src/Controller/ArticleController.php
- -
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' message: '#^PHPDoc tag @param references unknown parameter\: \$rawTags$#'
identifier: function.alreadyNarrowedType identifier: parameter.notFound
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: '#^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 count: 1
path: src/Service/MagazineContentService.php path: src/Nostr/Nip10Kind1ArticleReplyTags.php
- -
message: '#^Cannot call method __invoke\(\) on callable\.$#' message: '#^Cannot call method __invoke\(\) on callable\.$#'
@ -474,194 +36,26 @@ parameters:
count: 4 count: 4
path: src/Service/MagazineRefresher.php 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\.$#' message: '#^Method App\\Service\\NostrClient\:\:fetchKind5DeletionEventsForAuthors\(\) has invalid return type App\\Service\\stdClass\.$#'
identifier: class.notFound identifier: class.notFound
count: 1 count: 1
path: src/Service/NostrClient.php 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\.$#' message: '#^PHPDoc tag @return with type swentel\\nostr\\Event\\Event\|null is not subtype of native type stdClass\|null\.$#'
identifier: return.phpDocType identifier: return.phpDocType
count: 1 count: 1
path: src/Service/NostrClient.php 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: 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\<non\-empty\-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\.$#' message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\<int, mixed\>\|list\<mixed\> is not subtype of native type object\.$#'
identifier: parameter.phpDocType identifier: parameter.phpDocType
count: 1 count: 1
path: src/Service/NostrShareMenuBuilder.php 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\>\.$#' message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected identifier: method.protected
count: 1 count: 1
path: src/Service/TopicIndexService.php 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

32
src/Repository/ArticleHighlightRepository.php

@ -22,7 +22,9 @@ class ArticleHighlightRepository extends ServiceEntityRepository
/** /**
* Newest highlights across published/archived long-form, for the home aside. * 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<ArticleHighlight> * @return list<ArticleHighlight>
*/ */
@ -32,18 +34,42 @@ class ArticleHighlightRepository extends ServiceEntityRepository
return []; 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') $qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a') ->innerJoin('h.article', 'a')
->where('a.eventStatus IN (:st)') ->where('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) ->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('h.eventCreatedAt', 'DESC') ->orderBy('h.eventCreatedAt', 'DESC')
->addOrderBy('h.id', 'DESC') ->addOrderBy('h.id', 'DESC')
->setMaxResults($limit); ->setMaxResults($fetchLimit);
/** @var list<ArticleHighlight> $rows */ /** @var list<ArticleHighlight> $rows */
$rows = $qb->getQuery()->getResult(); $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;
} }
/** /**

41
src/Service/CacheService.php

@ -29,7 +29,6 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
private EventRepository $eventRepository, private EventRepository $eventRepository,
private LoggerInterface $logger, private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper, private NostrKeyHelper $nostrKeyHelper,
private NostrNip65RelayUrls $nip65RelayUrls,
private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder, private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder,
) { ) {
} }
@ -223,26 +222,6 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
return $n; 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<list<string>> * @return list<list<string>>
*/ */
@ -432,24 +411,4 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
]; ];
} }
/**
* @param list<list<string>>|array $tags
* @return list<string>
*/
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');
});
}
} }

220
src/Service/NostrClient.php

@ -17,7 +17,6 @@ use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet; use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request; use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription; 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 * 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 EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
private readonly ArticleFactory $articleFactory, private readonly ArticleFactory $articleFactory,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly string $projectDir, private readonly string $projectDir,
private readonly NostrRelayRequestFactory $relayRequestFactory, private readonly NostrRelayRequestFactory $relayRequestFactory,
@ -140,66 +138,6 @@ class NostrClient
return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix(); return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix();
} }
/**
* Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys).
*
* @param list<string> $authorPubkeyHex
* @return array<string, \stdClass> 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). * 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); 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 * 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 * (kind, pubkey), so multi-relay results are merged to the live revision per
@ -465,49 +362,6 @@ class NostrClient
return $this->wireMerge->mergeNip33ParameterizedWireEvents($events); 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 public function publishEvent(Event $event, array $relays): array
{ {
$eventMessage = new EventMessage($event); $eventMessage = new EventMessage($event);
@ -604,13 +458,20 @@ class NostrClient
} }
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); $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 { try {
// Create request using the helper method for forest relay set // Create request using the helper method for forest relay set
$request = $this->nostrRelayQuery->createNostrRequest( $request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet, defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind], kinds: [$kind],
filters: [ filters: [
'authors' => [$author], 'authors' => [$authorHex],
'tag' => ['#d', [$slug]] 'tag' => ['#d', [$slug]]
], ],
relaySet: $authorRelaySet relaySet: $authorRelaySet
@ -623,9 +484,8 @@ class NostrClient
if (!empty($events)) { if (!empty($events)) {
$kindI = (int) $kind; $kindI = (int) $kind;
$authorH = $this->wireMerge->authorIdentToHexLower($author); $event = $this->wireMerge->isNip33ParameterizedKind($kindI)
$event = $this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null ? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorHex, (string) $slug)
? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug)
: null; : null;
if ($event === null) { if ($event === null) {
$event = $events[0]; $event = $events[0];
@ -780,9 +640,13 @@ class NostrClient
if (empty($pubkey) || empty($identifier)) { if (empty($pubkey) || empty($identifier)) {
return null; return null;
} }
$authorHex = $this->wireMerge->authorIdentToHexLower($pubkey);
if ($authorHex === null) {
return null;
}
// Try author's relays first // 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); $relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays);
// Create request using the helper method // Create request using the helper method
@ -790,7 +654,7 @@ class NostrClient
defaultRelaySet: $this->defaultRelaySet, defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind], kinds: [$kind],
filters: [ filters: [
'authors' => [$pubkey], 'authors' => [$authorHex],
'tag' => ['#d', [$identifier]] 'tag' => ['#d', [$identifier]]
], ],
relaySet: $relaySet relaySet: $relaySet
@ -810,14 +674,12 @@ class NostrClient
defaultRelaySet: $this->defaultRelaySet, defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind], kinds: [$kind],
filters: [ filters: [
'authors' => [$pubkey], 'authors' => [$authorHex],
'tag' => ['#d', [$identifier]] 'tag' => ['#d', [$identifier]]
] ]
); );
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) { $events = $this->nostrRelayQuery->processResponse($request->send(), static fn (object $e) => $e);
return $event;
});
return !empty($events) ? $events[0] : null; 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 public function getArticles(array $slugs): array
{ {
$articles = []; $articles = [];

3
src/Util/CommonMark/Converter.php

@ -4,6 +4,7 @@ namespace App\Util\CommonMark;
use App\Nostr\Nip19Codec; use App\Nostr\Nip19Codec;
use App\Service\CacheService; use App\Service\CacheService;
use App\Util\NpubBech32Extractor;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
@ -36,6 +37,8 @@ readonly class Converter
*/ */
public function convertToHTML(string $markdown): string public function convertToHTML(string $markdown): string
{ {
$this->cacheService->prefetchMetadataForNpubs(NpubBech32Extractor::extractFromText($markdown));
// Check if the article has more than three headings // Check if the article has more than three headings
// Match all headings (from level 1 to 6) // Match all headings (from level 1 to 6)
preg_match_all('/^#+\s.*$/m', $markdown, $matches); preg_match_all('/^#+\s.*$/m', $markdown, $matches);

26
src/Util/NpubBech32Extractor.php

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Util;
/**
* Collects npub1… bech32 identifiers from free text (markdown, HTML) for batched kind-0 prefetch.
*/
final class NpubBech32Extractor
{
/**
* @return list<string>
*/
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]));
}
}
Loading…
Cancel
Save