diff --git a/.env.dist b/.env.dist index 0e1c807..406b333 100644 --- a/.env.dist +++ b/.env.dist @@ -49,10 +49,15 @@ MYSQL_ROOT_PASSWORD=root_password # compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example: # HTTP_PUBLISH=127.0.0.1:9080 # HTTP_PUBLISH=80 +# Optional: silence verbose Symfony deprecation output in the CLI. See Symfony docs for values (max[direct]=N, etc.). +# SYMFONY_DEPRECATIONS_HELPER=weak +# Optional: Nostr per-relay WebSocket timeout in seconds. Default: `nostr_relay_request_timeout_sec` in config/unfold.yaml +# (also passed to bin/nostr_relay_request_worker.php as NOSTR_RELAY_REQUEST_TIMEOUT when the parent sets it). +# NOSTR_RELAY_REQUEST_TIMEOUT=12 ###< docker ### ###> doctrine/doctrine-bundle ### # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml +# `serverVersion` is also set in `config/packages/doctrine.yaml` to avoid DBAL 4 "MySQL earlier than 8" deprecation noise. DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@database:3306/${MYSQL_DATABASE}?serverVersion=${MYSQL_VERSION}&charset=${MYSQL_CHARSET}" ###< doctrine/doctrine-bundle ### diff --git a/bin/nostr_relay_request_worker.php b/bin/nostr_relay_request_worker.php index 334e947..762f332 100644 --- a/bin/nostr_relay_request_worker.php +++ b/bin/nostr_relay_request_worker.php @@ -46,8 +46,12 @@ if (!\is_object($msg) || !($msg instanceof \swentel\nostr\Message\RequestMessage $relaySet = new \swentel\nostr\Relay\RelaySet(); $relaySet->addRelay(new \swentel\nostr\Relay\Relay($relayUrl)); $request = new \swentel\nostr\Request\Request($relaySet, $msg); +$relayTimeout = (int) (getenv('NOSTR_RELAY_REQUEST_TIMEOUT') ?: 12); +if ($relayTimeout < 1) { + $relayTimeout = 12; +} if (method_exists($request, 'setTimeout')) { - $request->setTimeout(15); + $request->setTimeout($relayTimeout); } try { diff --git a/composer.json b/composer.json index 8fd181a..bb7bbe0 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "ext-ctype": "*", "ext-iconv": "*", "ext-openssl": "*", + "cweagans/composer-patches": "^1.7", "doctrine/dbal": "^4.2", "doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-migrations-bundle": "^3.3", @@ -18,7 +19,6 @@ "laminas/laminas-diactoros": "^3.6", "league/commonmark": "^2.7", "league/html-to-markdown": "*", - "nostriphant/nip-19": "^2.0", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.0", "runtime/frankenphp-symfony": "^0.2.0", @@ -58,7 +58,8 @@ "php-http/discovery": true, "symfony/flex": true, "symfony/runtime": true, - "endroid/installer": true + "endroid/installer": true, + "cweagans/composer-patches": true }, "sort-packages": true }, @@ -106,6 +107,11 @@ }, "runtime": { "dotenv_overload": false + }, + "patches": { + "swentel/nostr-php": { + "Fix PHPDoc for Symfony ErrorHandler (setTags, connect)": "patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch" + } } }, "require-dev": { diff --git a/composer.lock b/composer.lock index ea248b0..a99afed 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "37decb6d8e5656d920a95981bfb25c0e", + "content-hash": "138ac13dfe47c4f697e248fc51668cf4", "packages": [ { "name": "bitwasp/bech32", @@ -201,6 +201,54 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "cweagans/composer-patches", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-patches.git", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3.0" + }, + "require-dev": { + "composer/composer": "~1.0 || ~2.0", + "phpunit/phpunit": "~4.6" + }, + "type": "composer-plugin", + "extra": { + "class": "cweagans\\Composer\\Patches" + }, + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a way to patch Composer packages.", + "support": { + "issues": "https://github.com/cweagans/composer-patches/issues", + "source": "https://github.com/cweagans/composer-patches/tree/1.7.3" + }, + "time": "2022-12-20T22:53:13+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2597,50 +2645,6 @@ }, "time": "2026-02-13T03:05:33+00:00" }, - { - "name": "nostriphant/nip-19", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/nostriphant/nip-19.git", - "reference": "5b362ab27e428f666f021743dddde9fecfe895c5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nostriphant/nip-19/zipball/5b362ab27e428f666f021743dddde9fecfe895c5", - "reference": "5b362ab27e428f666f021743dddde9fecfe895c5", - "shasum": "" - }, - "require": { - "php": "^8.3" - }, - "require-dev": { - "pestphp/pest": "^2.35" - }, - "type": "library", - "autoload": { - "psr-4": { - "nostriphant\\NIP19\\": "src/", - "nostriphant\\NIP19Tests\\": "tests/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Rik Meijer", - "email": "rik@nostriphant.dev" - } - ], - "description": "Nostr NIP-19 bech32-encoded entities in PHP", - "support": { - "issues": "https://github.com/nostriphant/nip-19/issues", - "source": "https://github.com/nostriphant/nip-19/tree/2.0" - }, - "time": "2024-11-26T15:37:43+00:00" - }, { "name": "nyholm/psr7", "version": "1.8.2", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 5f35f17..f225323 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 - # IMPORTANT: You MUST configure your server version, - # either here or in the DATABASE_URL env var (see .env file) - #server_version: '8.0' + # 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' charset: utf8mb4 default_table_options: charset: utf8mb4 diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index ddcc8ac..264a7aa 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -24,6 +24,8 @@ when@dev: path: "php://stderr" # Min level info: debug stays out of stderr (file only). level: info + # User deprecations (vendor) still land in var/log/…; avoid duplicating to Docker stderr. + channels: [ "!event", "!deprecation" ] console: type: console process_psr_3_messages: false diff --git a/config/services.yaml b/config/services.yaml index e675b3d..8a34537 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -41,6 +41,7 @@ services: $articleRelayUrls: '%article_relays%' $profileRelayUrls: '%profile_relays%' $projectDir: '%kernel.project_dir%' + $relayRequestTimeoutSec: '%nostr_relay_request_timeout_sec%' App\Service\ArticleCommentThreadLoader: arguments: $appCachePool: '@cache.replies' diff --git a/config/unfold.yaml b/config/unfold.yaml index f6b3130..31190d7 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -1,6 +1,9 @@ # Site identity and theme (see assets/theme/local/ to override default assets). parameters: + # Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm. + nostr_relay_request_timeout_sec: 12 + name: 'Nostr, Curated Thoughtfully' short_name: 'Imwald Blog' description: 'A selection of my own Nostr long-form articles and articles from other authors, selected for the quality of their writing and the depth of their analysis.' diff --git a/patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch b/patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch new file mode 100644 index 0000000..0f078d5 --- /dev/null +++ b/patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch @@ -0,0 +1,22 @@ +--- a/src/EventInterface.php 2026-04-27 18:53:54.019977813 +0200 ++++ b/src/EventInterface.php 2026-04-27 18:53:54.021843273 +0200 +@@ -109,7 +109,7 @@ + /** + * Set the event tags with values. + * +- * @param array $tags[] ++ * @param array $tags + * [ + * ["e", "..."], + * ["p", "...", "..."], +--- a/src/RelaySetInterface.php 2026-04-27 18:53:54.021655232 +0200 ++++ b/src/RelaySetInterface.php 2026-04-27 18:53:54.023843373 +0200 +@@ -54,7 +54,7 @@ + /** + * Connect to all relays in this set. + * +- * @param bool $throwOnErrorx ++ * @param bool $throwOnError + * If true, throw an exception if any relay fails to connect. + * If false, return false if any relay fails to connect. + * @return bool diff --git a/scripts/docker-prewarm.sh b/scripts/docker-prewarm.sh index 272a1e0..576a4fe 100755 --- a/scripts/docker-prewarm.sh +++ b/scripts/docker-prewarm.sh @@ -17,6 +17,9 @@ echo "==> articles:get (last 2 months → now)" docker compose exec -T php php bin/console articles:get -- '-2 month' 'now' echo "==> app:prewarm" -docker compose exec -T php php bin/console app:prewarm +# Unbounded PHP time: MagazineRefresher no longer sets a ~210s cap, but -d is a backstop for slow +# Nostr WebSocket I/O. Optional: `export SYMFONY_DEPRECATIONS_HELPER=weak` or +# `NOSTR_RELAY_REQUEST_TIMEOUT=…` to override config/unfold.yaml (see .env.dist). +docker compose exec -T php php -d max_execution_time=0 bin/console app:prewarm echo "Done." diff --git a/src/Command/ArticleHighlightsAuditCommand.php b/src/Command/ArticleHighlightsAuditCommand.php new file mode 100644 index 0000000..a8035e6 --- /dev/null +++ b/src/Command/ArticleHighlightsAuditCommand.php @@ -0,0 +1,160 @@ + injections succeed (debugging)', +)] +final class ArticleHighlightsAuditCommand extends Command +{ + public function __construct( + private readonly ArticleRepository $articleRepository, + private readonly ArticleHighlightRepository $articleHighlightRepository, + private readonly Converter $converter, + private readonly ArticleBodyHighlightInjector $articleBodyHighlightInjector, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('slug', InputArgument::REQUIRED, 'Article d-identifier (slug), e.g. bitcoin-is-time') + ->addOption('npub', null, InputOption::VALUE_OPTIONAL, 'If set, must match the article author (npub1…)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $slug = trim((string) $input->getArgument('slug')); + if ($slug === '') { + $io->error('Empty slug.'); + + return Command::FAILURE; + } + + $article = $this->articleRepository->findLatestBySlug($slug); + if (null === $article) { + $io->error('No article row for this slug.'); + + return Command::FAILURE; + } + + $key = new Key(); + $expectedNpub = $key->convertPublicKeyToBech32((string) $article->getPubkey()); + $optNpub = $input->getOption('npub'); + if (\is_string($optNpub) && $optNpub !== '') { + if ($key->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) { + $io->error('npub does not match this article’s author (expected: '.$expectedNpub.').'); + + return Command::FAILURE; + } + } + + $io->title('Article highlights audit: '.$slug); + $io->writeln('Author npub: '.$expectedNpub.''); + $io->writeln('Article id: '.(string) $article->getId().' · kind: '. + ($article->getKind()?->value ?? 'null').''); + + $highlights = $this->articleHighlightRepository->findByArticle($article); + $io->writeln('Rows from findByArticle: '.\count($highlights).''); + + try { + $html = $this->converter->convertToHTML((string) $article->getContent()); + } catch (CommonMarkException $e) { + $io->error('CommonMark: '.$e->getMessage()); + + return Command::FAILURE; + } + + $out = $this->articleBodyHighlightInjector->inject($html, $highlights); + $injected = $out['injectedEventIds']; + $markCount = \substr_count($out['html'], 'user-highlight__marker'); + $io->writeln('Injected event ids with all highlights together (duplicates = same passage): '.\count($injected).''); + $io->writeln(' count in body: '.$markCount.''); + + $io->section('Each highlight in isolation (same HTML, one 9802 at a time)'); + $rows = []; + $isolatedOk = 0; + foreach ($highlights as $h) { + if (! $h instanceof ArticleHighlight) { + continue; + } + $eid = \strtolower($h->getEventId()); + $one = $this->articleBodyHighlightInjector->inject($html, [$h]); + $found = 1 === \preg_match( + '/\bid=([\'"])highlight-'.preg_quote($eid, '/').'\1/i', + $one['html'] + ); + if ($found) { + ++$isolatedOk; + } + $snippet = $this->excerptOneLine((string) $h->getContent(), 72); + $rows[] = [ + $found ? 'yes' : 'no', + $eid, + $snippet, + ]; + } + $io->table(['Match', 'event id', 'stored `content` (excerpt)'], $rows); + if ($isolatedOk < \count($highlights)) { + $io->writeln( + '‘Match: no’ means the stored passage is absent from the flattened body text, or it diverges '. + '(soft hyphens, smart quotes, edits, footnotes, etc.). Re-sync kind 9802 from relays, or adjust matching in ArticleBodyHighlightInjector.' + ); + } + + if ($markCount < 1 && \count($highlights) > 0) { + $io->warning('With all highlights together, nothing was injected. Per-row check above still shows if any row matches in isolation.'); + } elseif (\count($highlights) < 1) { + $io->note('No article_highlight rows for this slug+author. Run prewarm highlight sync or check MySQL.'); + } elseif ($markCount > 0) { + $io->success('At least one was produced when all rows were passed to the injector together.'); + } + + if ($io->isVerbose() && $injected !== []) { + $io->section('Injected event ids (batch, may include several per passage)'); + $io->listing($injected); + } + + return Command::SUCCESS; + } + + /** + * One line for the table: reflect {@see ArticleHighlight::getContent()} bytes faithfully. + * Only line breaks are folded to a space so the row stays one line — we do not collapse + * {@see \p{Z}} or remove U+00AD (soft hyphen); doing that made passages look like they + * contained ASCII spaces the Nostr `content` never had. + */ + private function excerptOneLine(string $s, int $max): string + { + $s = (string) \preg_replace('/\R/u', ' ', $s); + if (\mb_strlen($s, 'UTF-8') > $max) { + $s = \mb_substr($s, 0, $max - 1, 'UTF-8').'…'; + } + + return $s; + } +} diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 649f224..e8f4458 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -80,6 +80,11 @@ final class PrewarmCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $this->disableCliExecutionTimeLimit(); + $socketTo = (int) $this->params->get('nostr_relay_request_timeout_sec'); + if ($socketTo > 0) { + // Align PHP stream layer with Nostr WebSocket timeout (avoids 60s default stalling a relay step). + ini_set('default_socket_timeout', (string) $socketTo); + } $io = new SymfonyStyle($input, $output); $keys = new Key(); @@ -181,6 +186,10 @@ final class PrewarmCommand extends Command $io->note('Skipping magazine (--no-magazine).'); } + // MagazineRefresher used to set max_execution_time (~2×budget); re-assert unlimited before + // any later Nostr phase (long-form can exceed that old cap and was causing max-time fatals). + $this->disableCliExecutionTimeLimit(); + $io->section('Long-form in DB (category `a` tags — refresh from Nostr)'); try { $n = $this->magazineContent->ingestLongformForAllMagazineCategories(); @@ -224,7 +233,6 @@ final class PrewarmCommand extends Command $io->warning('Featured author reconcile failed: '.$e->getMessage()); } - // MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata. $this->disableCliExecutionTimeLimit(); if (!$input->getOption('no-deletions')) { diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 82391c7..0a47acd 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -12,11 +12,10 @@ use App\Form\EditorType; use App\Service\ArticleCommentThreadLoader; use App\Service\NostrClient; use App\Service\CacheService; +use App\Nostr\Nip19Codec; use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; use League\CommonMark\Exception\CommonMarkException; -use nostriphant\NIP19\Bech32; -use nostriphant\NIP19\Data\NAddr; use Psr\Log\LoggerInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; @@ -253,15 +252,14 @@ class ArticleController extends AbstractController * @throws \Exception */ #[Route('/article/{naddr}', name: 'article-naddr')] - public function naddr(NostrClient $nostrClient, $naddr) + public function naddr(NostrClient $nostrClient, Nip19Codec $nip19, $naddr) { - $decoded = new Bech32($naddr); + $decoded = $nip19->decode($naddr); if ($decoded->type !== 'naddr') { throw new \Exception('Invalid naddr'); } - /** @var NAddr $data */ $data = $decoded->data; $slug = $data->identifier; $relays = $data->relays; diff --git a/src/Controller/EventController.php b/src/Controller/EventController.php index fccb4a2..651a3f5 100644 --- a/src/Controller/EventController.php +++ b/src/Controller/EventController.php @@ -4,13 +4,12 @@ declare(strict_types=1); namespace App\Controller; +use App\Nostr\Nip19Codec; use App\Service\NostrClient; use App\Service\NostrLinkParser; use App\Service\NostrShareMenuBuilder; use App\Service\CacheService; use Exception; -use nostriphant\NIP19\Bech32; -use nostriphant\NIP19\Data; use Psr\Log\LoggerInterface; use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -28,6 +27,7 @@ class EventController extends AbstractController public function index( $nevent, Request $request, + Nip19Codec $nip19, NostrClient $nostrClient, CacheService $cacheService, NostrLinkParser $nostrLinkParser, @@ -38,11 +38,9 @@ class EventController extends AbstractController try { // Decode nevent - nevent1... is a NIP-19 encoded event identifier - $decoded = new Bech32($nevent); + $decoded = $nip19->decode($nevent); $logger->info('Decoded event', ['decoded' => json_encode($decoded)]); - // Get the event using the event ID - /** @var Data $data */ $data = $decoded->data; $logger->info('Event data', ['data' => json_encode($data)]); @@ -50,12 +48,12 @@ class EventController extends AbstractController // Sort which event type this is using $data->type switch ($decoded->type) { case 'note': - // Handle note (regular event) + $eventHex = (string) ($data->data ?? ''); $relays = $data->relays ?? []; if (!\is_array($relays)) { $relays = []; } - $event = $nostrClient->getEventById($data->identifier, $relays); + $event = $nostrClient->getEventById($eventHex, $relays); break; case 'nprofile': diff --git a/src/Nostr/Nip19Addressable.php b/src/Nostr/Nip19Addressable.php index 3a60706..fb52c79 100644 --- a/src/Nostr/Nip19Addressable.php +++ b/src/Nostr/Nip19Addressable.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Nostr; use App\Entity\Event; -use nostriphant\NIP19\Bech32; /** * NIP-33 / NIP-19 helpers: naddr for parameterized replaceable events (kind:pubkey:d). @@ -63,11 +62,6 @@ final class Nip19Addressable throw new \InvalidArgumentException('Invalid pubkey hex for naddr.'); } - return (string) Bech32::naddr( - kind: $kind, - pubkey: $pubkeyHex, - identifier: $dIdentifier, - relays: $relays, - ); + return (new Nip19Codec())->encodeNaddr($kind, $pubkeyHex, $dIdentifier, $relays); } } diff --git a/src/Nostr/Nip19Codec.php b/src/Nostr/Nip19Codec.php new file mode 100644 index 0000000..87dea31 --- /dev/null +++ b/src/Nostr/Nip19Codec.php @@ -0,0 +1,120 @@ +nip19Helper = $nip19Helper ?? new Nip19Helper(); + } + + public function decode(string $bech32): object + { + $pos = strrpos($bech32, '1'); + if (false === $pos || $pos < 1) { + throw new \InvalidArgumentException('Invalid bech32 string'); + } + $hrp = substr($bech32, 0, $pos); + $raw = $this->nip19Helper->decode($bech32); + + $out = new \stdClass(); + + if ($hrp === 'npub' || $hrp === 'nsec') { + if (!\is_array($raw) || !isset($raw[1]) || !\is_array($raw[1])) { + throw new \RuntimeException('Unexpected npub/nsec decode shape'); + } + $out->type = $hrp; + $d = new \stdClass(); + $hex = ''; + foreach ($raw[1] as $byte) { + $hex .= str_pad(\dechex($byte & 0xff), 2, '0', STR_PAD_LEFT); + } + $d->data = $hex; + $out->data = $d; + + return $out; + } + + if ($hrp === 'note') { + if (!\is_array($raw) || !isset($raw['event_id']) || !\is_string($raw['event_id'])) { + throw new \RuntimeException('Unexpected note decode shape'); + } + $out->type = 'note'; + $d = new \stdClass(); + $d->data = $raw['event_id']; + $d->relays = []; + $out->data = $d; + + return $out; + } + + if (!\is_array($raw)) { + throw new \RuntimeException('Unexpected NIP-19 decode shape'); + } + + $out->type = $hrp; + $d = new \stdClass(); + if ($hrp === 'nprofile') { + $d->pubkey = $raw['pubkey'] ?? ''; + $d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; + } elseif ($hrp === 'nevent') { + $d->id = (string) ($raw['event_id'] ?? ''); + $d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; + $d->author = \array_key_exists('author', $raw) ? (string) $raw['author'] : null; + if ($d->author === '') { + $d->author = null; + } + $d->pubkey = $d->author; + $d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : null; + } elseif ($hrp === 'naddr') { + $d->identifier = (string) ($raw['identifier'] ?? ''); + $d->pubkey = (string) ($raw['author'] ?? ''); + $d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; + $d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : 0; + } else { + throw new \InvalidArgumentException('Unsupported NIP-19 prefix: '.$hrp); + } + $out->data = $d; + + return $out; + } + + public function encodeNevent(string $eventIdHex, array $relays, string $authorHex, int $kind): string + { + $e = new Event(); + $e->setId(strtolower($eventIdHex)); + $e->setPublicKey(strtolower($authorHex)); + $e->setKind($kind); + + return $this->nip19Helper->encodeEvent($e, $relays, $authorHex, $kind); + } + + public function encodeNaddr(int $kind, string $pubkeyHex, string $dTag, array $relays = []): string + { + $pk = strtolower($pubkeyHex); + if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { + throw new \InvalidArgumentException('Invalid pubkey hex for naddr.'); + } + if ($dTag === '') { + throw new \InvalidArgumentException('d tag required for naddr'); + } + $e = new Event(); + $e->setPublicKey($pk); + $e->setKind($kind); + $e->setId(str_repeat('0', 64)); + + return $this->nip19Helper->encodeAddr($e, $dTag, $kind, $pk, $relays); + } +} diff --git a/src/Repository/ArticleHighlightRepository.php b/src/Repository/ArticleHighlightRepository.php index 59565cd..7639aa9 100644 --- a/src/Repository/ArticleHighlightRepository.php +++ b/src/Repository/ArticleHighlightRepository.php @@ -69,7 +69,8 @@ class ArticleHighlightRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('h') ->innerJoin('h.article', 'a') - ->where('a.pubkey = :pubkey') + // Hex pubkeys are case-insensitive; utf8mb4_bin would otherwise miss rows. + ->where('LOWER(a.pubkey) = LOWER(:pubkey)') ->andWhere('a.slug = :slug') ->setParameter('pubkey', $pubkey) ->setParameter('slug', $slug) diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index 1e51283..28d84ea 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -100,27 +100,44 @@ final class ArticleBodyHighlightInjector libxml_use_internal_errors($prev); libxml_clear_errors(); } - // getElementById is unreliable for HTML loaded without a DTD; use XPath, then a div scan, then a tree walk. + $this->root = $this->resolveRootWrapperElement(); + if (null === $this->root) { + // Some libxml/fragment combinations drop the root with HTML_NOIMPLIED; parse a plain wrapper + $this->dom = new DOMDocument('1.0', 'UTF-8'); + $prevInner = libxml_use_internal_errors(true); + try { + $this->dom->loadHTML( + ''.'
'.$html.'
', + \LIBXML_HTML_NODEFDTD + ); + $this->root = $this->resolveRootWrapperElement(); + } finally { + libxml_use_internal_errors($prevInner); + libxml_clear_errors(); + } + } + } + + private function resolveRootWrapperElement(): ?DOMElement + { $xp = new DOMXPath($this->dom); $nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]'); if (false !== $nodes && $nodes->length > 0) { $first = $nodes->item(0); - $this->root = $first instanceof DOMElement ? $first : null; - } else { - $this->root = null; - } - if (null === $this->root) { - $de = $this->dom->documentElement; - if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) { - $this->root = $de; - } + + return $first instanceof DOMElement ? $first : null; } - if (null === $this->root) { - $this->root = $this->findFirstDivById(self::ROOT_ID); + $de = $this->dom->documentElement; + if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) { + return $de; } - if (null === $this->root) { - $this->root = $this->findElementByIdFallback(self::ROOT_ID); + $d = $this->findFirstDivById(self::ROOT_ID); + if (null !== $d) { + return $d; } + $el = $this->findElementByIdFallback(self::ROOT_ID); + + return $el instanceof DOMElement ? $el : null; } private function findFirstDivById(string $id): ?DOMElement @@ -385,11 +402,14 @@ final class ArticleBodyHighlightInjector */ private function injectionNeedleBasesInPriority(ArticleHighlight $h): array { - $c = \trim($h->getContent()); + $rawContent = (string) $h->getContent(); $tags = $h->getTags(); - $ctx = \trim(HighlightEventTags::contextFromTags($tags)); - $fullPassage = \trim(HighlightEventTags::fullPassageForHighlightDisplay($c, $tags)); - $tq = \trim(HighlightEventTags::textquoteselectorPassageFromTags($tags)); + $c = HighlightEventTags::trimNostrText($rawContent); + $ctx = HighlightEventTags::trimNostrText(HighlightEventTags::contextFromTags($tags)); + $fullPassage = HighlightEventTags::trimNostrText( + HighlightEventTags::fullPassageForHighlightDisplay($rawContent, $tags) + ); + $tq = HighlightEventTags::trimNostrText(HighlightEventTags::textquoteselectorPassageFromTags($tags)); $out = []; $seen = []; // NIP-84: `context` = full quote; `content` = highlighted span. Missing/empty `context` is diff --git a/src/Service/HighlightSyncService.php b/src/Service/HighlightSyncService.php index 55a701c..1767778 100644 --- a/src/Service/HighlightSyncService.php +++ b/src/Service/HighlightSyncService.php @@ -73,7 +73,8 @@ final class HighlightSyncService } $excerpt = HighlightEventTags::excerptForFeed($content, $tags); if ($excerpt === '') { - $excerpt = \mb_substr(\trim($content), 0, 240); + $t = HighlightEventTags::trimNostrText($content); + $excerpt = $t !== '' ? \mb_substr($t, 0, 240) : ''; } $row = $this->highlightRepository->findOneBy(['eventId' => $eid]); diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index 207c0a6..f99f3a9 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -59,8 +59,11 @@ final class MagazineRefresher $dTag = (string) $this->params->get('d_tag'); $preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs); - // Allow enough PHP wall time for a slow root fetch plus the full category-phase budget. - $this->applyExecutionTimeCap(2 * $budgetSeconds); + // Do not cap max_execution_time here. A previous design used 2×budget+30s (capped 210) which + // outlived this method and then killed the rest of app:prewarm (long-form / highlights) + // while a relay WebSocket was still connecting. Wall time is bounded by $deadline below + // (category phase) and by Nostr request timeouts; PHP should stay unlimited in CLI. + $this->ensureUnlimitedPhpExecutionTime(); $defaultRelay = (string) $this->params->get('default_relay'); $relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay); @@ -250,15 +253,10 @@ final class MagazineRefresher $this->appCache->save($item); } - /** - * One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft - * $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time. - */ - private function applyExecutionTimeCap(int $budgetSeconds): void + private function ensureUnlimitedPhpExecutionTime(): void { - $sec = max(30, min(700, $budgetSeconds + 30)); - @set_time_limit($sec); - @ini_set('max_execution_time', (string) $sec); + @\set_time_limit(0); + @\ini_set('max_execution_time', '0'); } /** diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 1a1dc56..c60dd98 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -10,7 +10,6 @@ use App\Enum\KindsEnum; use App\Factory\ArticleFactory; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; -use nostriphant\NIP19\Data; use Psr\Log\LoggerInterface; use swentel\nostr\Event\Event; use swentel\nostr\Filter\Filter; @@ -29,9 +28,6 @@ use Symfony\Contracts\Cache\ItemInterface; class NostrClient { - /** Per-relay WebSocket I/O cap (seconds), applied on each relay’s {@see \WebSocket\Client}. */ - private const RELAY_REQUEST_TIMEOUT_SEC = 15; - /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */ private const DISCUSSION_WORKER_GRACE_SEC = 5.0; /** Soft wall-time for parallel discussion collection before returning partial results. */ @@ -56,8 +52,9 @@ class NostrClient private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3; /** - * {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC - * each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts. + * {@see sendArticleDiscussionToRelaysSequential} visits relays one after another + * (~{@see NostrClient::$relayRequestTimeoutSec} s each). Keep this low so HTTP /fragment/comments + * and browsers do not hit 60–90s proxy cuts. */ private const MAX_SEQUENTIAL_RELAY_URLS = 3; @@ -75,6 +72,7 @@ class NostrClient /** * @param list $articleRelayUrls extra relays for the default set (default_relay is always first) * @param list $profileRelayUrls kind-0 / profile; merged for metadata (see {@see profileMetadataQueryRelayUrlList()}) + * @param int $relayRequestTimeoutSec Per-relay WebSocket I/O cap (see `nostr_relay_request_timeout_sec` in `config/unfold.yaml`) */ public function __construct( private readonly EntityManagerInterface $entityManager, @@ -87,6 +85,7 @@ class NostrClient private readonly array $profileRelayUrls, private readonly CacheInterface $relayQueryCache, private readonly string $projectDir, + private readonly int $relayRequestTimeoutSec = 12, ) { $this->defaultRelaySet = $this->buildArticleRelaySet(); } @@ -249,7 +248,7 @@ class NostrClient $request = new Request($relaySet, $requestMessage); // 1.9.4+: Request::setTimeout() drives getResponseFromRelay(). Older: only WebSocket client on Relay. if (method_exists($request, 'setTimeout')) { - $request->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC); + $request->setTimeout($this->relayRequestTimeoutSec); } else { $this->applyRelaySocketTimeoutToSet($relaySet); } @@ -265,7 +264,7 @@ class NostrClient foreach ($relaySet->getRelays() as $relay) { $client = $relay->getClient(); if (method_exists($client, 'setTimeout')) { - $client->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC); + $client->setTimeout($this->relayRequestTimeoutSec); } } } @@ -1586,7 +1585,8 @@ class NostrClient { $worker = $this->projectDir.'/bin/nostr_relay_request_worker.php'; $phpBinary = (new PhpExecutableFinder())->find() ?: 'php'; - $timeout = self::RELAY_REQUEST_TIMEOUT_SEC + (int) self::DISCUSSION_WORKER_GRACE_SEC; + $timeout = $this->relayRequestTimeoutSec + (int) self::DISCUSSION_WORKER_GRACE_SEC; + $workerTimeoutEnv = ['NOSTR_RELAY_REQUEST_TIMEOUT' => (string) $this->relayRequestTimeoutSec]; $rawPayload = serialize($requestMessage); $tmp = tempnam(sys_get_temp_dir(), 'nrq_'); @@ -1611,7 +1611,7 @@ class NostrClient null, (float) $timeout ); - $p->start(); + $p->start(null, $workerTimeoutEnv); $procs[$wss] = $p; } @@ -2530,7 +2530,6 @@ class NostrClient // Descriptor is an stdClass with properties: type and decoded if (is_object($descriptor) && isset($descriptor->type, $descriptor->decoded)) { // construct a request from the descriptor to fetch the event - /** @var Data $ata */ $data = json_decode($descriptor->decoded); if (!\is_object($data)) { $this->logger->error('Invalid descriptor decoded JSON', ['descriptor' => $descriptor]); diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php index c71cafd..77af71b 100644 --- a/src/Service/NostrLinkParser.php +++ b/src/Service/NostrLinkParser.php @@ -2,7 +2,7 @@ namespace App\Service; -use nostriphant\NIP19\Bech32; +use App\Nostr\Nip19Codec; use Psr\Log\LoggerInterface; readonly class NostrLinkParser @@ -12,7 +12,8 @@ readonly class NostrLinkParser private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i'; public function __construct( - private LoggerInterface $logger + private LoggerInterface $logger, + private Nip19Codec $nip19, ) {} /** @@ -83,7 +84,7 @@ readonly class NostrLinkParser if (preg_match(self::NOSTR_LINK_PATTERN, $url, $nostrMatch)) { $nostrId = $nostrMatch[1]; try { - $decoded = new Bech32($nostrId); + $decoded = $this->nip19->decode($nostrId); $nostrType = $decoded->type; $nostrData = $decoded->data; } catch (\Exception $e) { @@ -150,7 +151,7 @@ readonly class NostrLinkParser $position = $match[0][1]; // This check will be handled in parseLinks by sorting and merging try { - $decoded = new Bech32($identifier); + $decoded = $this->nip19->decode($identifier); $links[] = [ 'type' => $decoded->type, 'identifier' => $identifier, @@ -179,7 +180,7 @@ readonly class NostrLinkParser $position = $match[0][1]; $identifier = ltrim($raw, '@'); try { - $decoded = new Bech32($identifier); + $decoded = $this->nip19->decode($identifier); if (!\in_array($decoded->type, ['naddr', 'nevent'], true)) { continue; } diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php index b7e8251..8463072 100644 --- a/src/Service/NostrShareMenuBuilder.php +++ b/src/Service/NostrShareMenuBuilder.php @@ -8,8 +8,8 @@ use App\Dto\NostrShareMenuContext; use App\Entity\Article; use App\Entity\Event; use App\Nostr\Nip19Addressable; +use App\Nostr\Nip19Codec; use App\Repository\ArticleRepository; -use nostriphant\NIP19\Bech32; use swentel\nostr\Key\Key; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; @@ -42,12 +42,7 @@ final class NostrShareMenuBuilder if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); $neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) - ? (string) Bech32::nevent( - id: $eventIdHex, - relays: $relayHints, - author: $pubkeyHex, - kind: $kind, - ) + ? $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind) : null; return new NostrShareMenuContext( @@ -58,12 +53,7 @@ final class NostrShareMenuBuilder ); } if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { - $rebuilt = (string) Bech32::nevent( - id: $eventIdHex, - relays: $relayHints, - author: $pubkeyHex, - kind: $kind, - ); + $rebuilt = $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind); return new NostrShareMenuContext( $npub, @@ -138,6 +128,7 @@ final class NostrShareMenuBuilder public function __construct( private readonly MagazineIndexStore $magazineIndexStore, private readonly ArticleRepository $articleRepository, + private readonly Nip19Codec $nip19, #[Autowire('%npub%')] private readonly string $siteNpub, #[Autowire('%d_tag%')] @@ -213,12 +204,7 @@ final class NostrShareMenuBuilder $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $eid = strtolower((string) ($article->getEventId() ?? '')); $nevent = (64 === \strlen($eid) && ctype_xdigit($eid)) - ? (string) Bech32::nevent( - id: $eid, - relays: [], - author: $pk, - kind: $kind, - ) + ? $this->nip19->encodeNevent($eid, [], $pk, $kind) : null; return new NostrShareMenuContext( @@ -270,7 +256,7 @@ final class NostrShareMenuBuilder return $this->siteWithRootMenu(); } try { - $decoded = new Bech32($nevent); + $decoded = $this->nip19->decode($nevent); } catch (\Throwable) { return $this->siteWithRootMenu(); } @@ -291,12 +277,7 @@ final class NostrShareMenuBuilder $relays = $decoded->data->relays ?? []; $relays = \is_array($relays) ? $relays : []; if ($authorHex !== null) { - $rebuilt = (string) Bech32::nevent( - id: $eventId, - relays: $relays, - author: $authorHex, - kind: $kind, - ); + $rebuilt = $this->nip19->encodeNevent($eventId, $relays, $authorHex, $kind); return new NostrShareMenuContext( $this->nostrKey()->convertPublicKeyToBech32($authorHex), @@ -342,12 +323,7 @@ final class NostrShareMenuBuilder $npub = $this->nostrKey()->convertPublicKeyToBech32($pk); if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); - $neventForRev = (string) Bech32::nevent( - id: $id, - relays: [], - author: $pk, - kind: $kind, - ); + $neventForRev = $this->nip19->encodeNevent($id, [], $pk, $kind); return new NostrShareMenuContext( $npub, @@ -356,12 +332,7 @@ final class NostrShareMenuBuilder $this->feedJumble($naddr), ); } - $nevent = (string) Bech32::nevent( - id: $id, - relays: [], - author: $pk, - kind: $kind, - ); + $nevent = $this->nip19->encodeNevent($id, [], $pk, $kind); return new NostrShareMenuContext( $npub, diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php index 924e915..2f4bad8 100644 --- a/src/Util/CommonMark/Converter.php +++ b/src/Util/CommonMark/Converter.php @@ -2,6 +2,7 @@ namespace App\Util\CommonMark; +use App\Nostr\Nip19Codec; use App\Service\CacheService; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; @@ -25,9 +26,9 @@ use League\CommonMark\Renderer\HtmlDecorator; readonly class Converter { public function __construct( - private CacheService $cacheService - ) - { + private CacheService $cacheService, + private Nip19Codec $nip19Codec, + ) { } /** @@ -66,7 +67,7 @@ readonly class Converter $environment->addExtension(new TableExtension()); $environment->addExtension(new StrikethroughExtension()); // create a custom extension, that handles nostr mentions - $environment->addExtension(new NostrSchemeExtension($this->cacheService)); + $environment->addExtension(new NostrSchemeExtension($this->cacheService, $this->nip19Codec)); $environment->addExtension(new SmartPunctExtension()); $environment->addExtension(new EmbedExtension()); $environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content'])); diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php index 88eaa31..e397c1d 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php @@ -4,18 +4,20 @@ declare(strict_types=1); namespace App\Util\CommonMark\NostrSchemeExtension; +use App\Nostr\Nip19Codec; use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\InlineParserContext; -use nostriphant\NIP19\Bech32; -use nostriphant\NIP19\Data\NAddr; -use nostriphant\NIP19\Data\NEvent; /** * Matches bare or @-prefixed naddr1 / nevent1 (NIP-19), so they render like nostr:… links. */ final class NostrBareBech32Parser implements InlineParserInterface { + public function __construct( + private readonly Nip19Codec $nip19, + ) { + } public function getMatchDefinition(): InlineParserMatch { return InlineParserMatch::regex('(?:@)?(?:naddr1|nevent1)[0-9a-z]+'); @@ -32,16 +34,14 @@ final class NostrBareBech32Parser implements InlineParserInterface } try { - $decoded = new Bech32($bech); + $decoded = $this->nip19->decode($bech); } catch (\Throwable) { return false; } if ($decoded->type === 'naddr') { - /** @var NAddr $data */ $data = $decoded->data; $relays = $data->relays ?? []; - // NIP-19 naddr TLVs include author pubkey and kind; normalize like `nevent` if TLVs are missing. $author = $data->pubkey ?? ''; $kind = (int) ($data->kind ?? 0); $inlineContext->getContainer()->appendChild(new NostrSchemeData( @@ -52,10 +52,9 @@ final class NostrBareBech32Parser implements InlineParserInterface $kind )); } elseif ($decoded->type === 'nevent') { - /** @var NEvent $data */ $data = $decoded->data; $relays = $data->relays ?? []; - $author = $data->author ?? $data->pubkey ?? ''; + $author = (string) ($data->author ?? $data->pubkey ?? ''); $inlineContext->getContainer()->appendChild(new NostrSchemeData( 'nevent', $bech, diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php index bcc5fd3..b28e518 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php @@ -2,14 +2,18 @@ namespace App\Util\CommonMark\NostrSchemeExtension; +use App\Nostr\Nip19Codec; use League\CommonMark\Node\Node; use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; use League\CommonMark\Util\HtmlElement; -use nostriphant\NIP19\Bech32; class NostrEventRenderer implements NodeRendererInterface { + public function __construct( + private readonly Nip19Codec $nip19, + ) { + } public function render(Node $node, ChildNodeRendererInterface $childRenderer) { if (!($node instanceof NostrSchemeData)) { @@ -28,7 +32,7 @@ class NostrEventRenderer implements NodeRendererInterface { $bech = $node->getSpecial(); try { - $decoded = new Bech32($bech); + $decoded = $this->nip19->decode($bech); $payload = json_decode(json_encode($decoded->data), true, 512, JSON_THROW_ON_ERROR); $decodedJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } catch (\Throwable) { diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php index 7db9a19..8b94527 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php @@ -2,6 +2,7 @@ namespace App\Util\CommonMark\NostrSchemeExtension; +use App\Nostr\Nip19Codec; use App\Service\CacheService; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Extension\ExtensionInterface; @@ -9,19 +10,21 @@ use League\CommonMark\Extension\ExtensionInterface; class NostrSchemeExtension implements ExtensionInterface { - public function __construct(private readonly CacheService $cacheService) - { + public function __construct( + private readonly CacheService $cacheService, + private readonly Nip19Codec $nip19, + ) { } public function register(EnvironmentBuilderInterface $environment): void { $environment - ->addInlineParser(new NostrBareBech32Parser(), 202) + ->addInlineParser(new NostrBareBech32Parser($this->nip19), 202) ->addInlineParser(new NostrMentionParser($this->cacheService), 200) - ->addInlineParser(new NostrSchemeParser(), 199) + ->addInlineParser(new NostrSchemeParser($this->nip19), 199) ->addInlineParser(new NostrRawNpubParser($this->cacheService), 198) - ->addRenderer(NostrSchemeData::class, new NostrEventRenderer(), 2) + ->addRenderer(NostrSchemeData::class, new NostrEventRenderer($this->nip19), 2) ->addRenderer(NostrMentionLink::class, new NostrMentionRenderer(), 1) ; } diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php index 6abac8b..0fce4dc 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php @@ -2,19 +2,16 @@ namespace App\Util\CommonMark\NostrSchemeExtension; +use App\Nostr\Nip19Codec; use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\InlineParserContext; -use nostriphant\NIP19\Bech32; -use nostriphant\NIP19\Data\NAddr; -use nostriphant\NIP19\Data\NEvent; -use nostriphant\NIP19\Data\NProfile; class NostrSchemeParser implements InlineParserInterface { - - public function __construct() - { + public function __construct( + private readonly Nip19Codec $nip19, + ) { } public function getMatchDefinition(): InlineParserMatch @@ -32,35 +29,29 @@ class NostrSchemeParser implements InlineParserInterface $bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX" try { - $decoded = new Bech32($bechEncoded); + $decoded = $this->nip19->decode($bechEncoded); switch ($decoded->type) { case 'npub': - // Use the decoded bech32 (npub1…). NPub::$data is the hex pubkey; NostrMentionLink /author routes expect npub1… $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded)); break; case 'nprofile': - /** @var NProfile $decodedProfile */ $decodedProfile = $decoded->data; $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey)); break; case 'nevent': - /** @var NEvent $decodedNpub */ $decodedEvent = $decoded->data; - $eventId = $decodedEvent->id; - $relays = $decodedEvent->relays; + $relays = $decodedEvent->relays ?? []; $author = $decodedEvent->author; $kind = $decodedEvent->kind; - $inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $bechEncoded, $relays, $author, $kind)); + $inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $bechEncoded, \is_array($relays) ? $relays : [], (string) $author, (int) ($kind ?? 0))); break; case 'naddr': - /** @var NAddr $decodedNpub */ $decodedEvent = $decoded->data; - $identifier = $decodedEvent->identifier; + $relays = $decodedEvent->relays ?? []; $pubkey = $decodedEvent->pubkey; - $kind = $decodedEvent->kind; - $relays = $decodedEvent->relays; - $inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, $relays, $pubkey, $kind)); + $kind = (int) ($decodedEvent->kind ?? 0); + $inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, \is_array($relays) ? $relays : [], (string) $pubkey, $kind)); break; case 'nrelay': // deprecated diff --git a/src/Util/HighlightEventTags.php b/src/Util/HighlightEventTags.php index 5bd3e67..596ffa8 100644 --- a/src/Util/HighlightEventTags.php +++ b/src/Util/HighlightEventTags.php @@ -60,6 +60,24 @@ final class HighlightEventTags return $out; } + /** + * Trims Nostr/Unicode spacing (U+00A0, U+200B, U+00AD, other {@see \p{Z}}, etc.) from both ends + * after standard {@see \trim} — NIP-84 clients often differ from the rendered body on edge spaces. + */ + public static function trimNostrText(string $s): string + { + $s = \trim($s, " \t\n\r\0\x0B"); + if ($s === '') { + return ''; + } + $edge = '\p{Z}\x{200B}\x{200C}\x{200D}\x{FEFF}'; + $s = (string) \preg_replace('/^['.$edge.']+/u', '', $s); + $s = (string) \preg_replace('/['.$edge.']+$/u', '', $s); + $s = \trim($s, " \t\n\r\0\x0B"); + + return $s; + } + /** * The full passage from the `context` tag (one tag may split across many values in some clients). */ @@ -510,15 +528,18 @@ final class HighlightEventTags */ public static function excerptForFeed(string $content, array $tags): string { - $c = \trim((string) $content); - if ($c !== '') { - return \mb_substr($c, 0, 400); + $raw = (string) $content; + if ($raw !== '') { + $c = self::trimNostrText($raw); + if ($c !== '') { + return \mb_substr($c, 0, 400); + } } - $ctx = \trim(self::contextFromTags($tags)); + $ctx = self::trimNostrText(self::contextFromTags($tags)); if ($ctx !== '') { return \mb_substr($ctx, 0, 400); } - $tq = \trim(self::excerptFromTextquoteselectorTags($tags)); + $tq = self::trimNostrText(self::excerptFromTextquoteselectorTags($tags)); return $tq !== '' ? $tq : ''; }