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 : '';
}