Browse Source

refactor build

imwald
Silberengel 21 hours ago
parent
commit
b440415474
  1. 7
      .env.dist
  2. 6
      bin/nostr_relay_request_worker.php
  3. 10
      composer.json
  4. 94
      composer.lock
  5. 6
      config/packages/doctrine.yaml
  6. 2
      config/packages/monolog.yaml
  7. 1
      config/services.yaml
  8. 3
      config/unfold.yaml
  9. 22
      patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch
  10. 5
      scripts/docker-prewarm.sh
  11. 160
      src/Command/ArticleHighlightsAuditCommand.php
  12. 10
      src/Command/PrewarmCommand.php
  13. 8
      src/Controller/ArticleController.php
  14. 12
      src/Controller/EventController.php
  15. 8
      src/Nostr/Nip19Addressable.php
  16. 120
      src/Nostr/Nip19Codec.php
  17. 3
      src/Repository/ArticleHighlightRepository.php
  18. 56
      src/Service/ArticleBodyHighlightInjector.php
  19. 3
      src/Service/HighlightSyncService.php
  20. 18
      src/Service/MagazineRefresher.php
  21. 21
      src/Service/NostrClient.php
  22. 11
      src/Service/NostrLinkParser.php
  23. 47
      src/Service/NostrShareMenuBuilder.php
  24. 9
      src/Util/CommonMark/Converter.php
  25. 15
      src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php
  26. 8
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  27. 13
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
  28. 29
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  29. 31
      src/Util/HighlightEventTags.php

7
.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: # 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=127.0.0.1:9080
# HTTP_PUBLISH=80 # 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 ### ###< docker ###
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # 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}" DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@database:3306/${MYSQL_DATABASE}?serverVersion=${MYSQL_VERSION}&charset=${MYSQL_CHARSET}"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###

6
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 = new \swentel\nostr\Relay\RelaySet();
$relaySet->addRelay(new \swentel\nostr\Relay\Relay($relayUrl)); $relaySet->addRelay(new \swentel\nostr\Relay\Relay($relayUrl));
$request = new \swentel\nostr\Request\Request($relaySet, $msg); $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')) { if (method_exists($request, 'setTimeout')) {
$request->setTimeout(15); $request->setTimeout($relayTimeout);
} }
try { try {

10
composer.json

@ -10,6 +10,7 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-openssl": "*", "ext-openssl": "*",
"cweagans/composer-patches": "^1.7",
"doctrine/dbal": "^4.2", "doctrine/dbal": "^4.2",
"doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^3.3",
@ -18,7 +19,6 @@
"laminas/laminas-diactoros": "^3.6", "laminas/laminas-diactoros": "^3.6",
"league/commonmark": "^2.7", "league/commonmark": "^2.7",
"league/html-to-markdown": "*", "league/html-to-markdown": "*",
"nostriphant/nip-19": "^2.0",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0", "phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
@ -58,7 +58,8 @@
"php-http/discovery": true, "php-http/discovery": true,
"symfony/flex": true, "symfony/flex": true,
"symfony/runtime": true, "symfony/runtime": true,
"endroid/installer": true "endroid/installer": true,
"cweagans/composer-patches": true
}, },
"sort-packages": true "sort-packages": true
}, },
@ -106,6 +107,11 @@
}, },
"runtime": { "runtime": {
"dotenv_overload": false "dotenv_overload": false
},
"patches": {
"swentel/nostr-php": {
"Fix PHPDoc for Symfony ErrorHandler (setTags, connect)": "patches/swentel-nostr-php-symfony-debugclassloader-docblocks.patch"
}
} }
}, },
"require-dev": { "require-dev": {

94
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "37decb6d8e5656d920a95981bfb25c0e", "content-hash": "138ac13dfe47c4f697e248fc51668cf4",
"packages": [ "packages": [
{ {
"name": "bitwasp/bech32", "name": "bitwasp/bech32",
@ -201,6 +201,54 @@
], ],
"time": "2025-08-20T19:15:30+00:00" "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", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@ -2597,50 +2645,6 @@
}, },
"time": "2026-02-13T03:05:33+00:00" "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", "name": "nyholm/psr7",
"version": "1.8.2", "version": "1.8.2",

6
config/packages/doctrine.yaml

@ -2,9 +2,9 @@ doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
driver: pdo_mysql driver: pdo_mysql
# IMPORTANT: You MUST configure your server version, # Pin version so DBAL 4 does not treat the server as "MySQL &lt; 8" and emit deprecations
# either here or in the DATABASE_URL env var (see .env file) # on every request (see AbstractMySQLDriver + serverVersion auto-detection).
#server_version: '8.0' server_version: '8.0'
charset: utf8mb4 charset: utf8mb4
default_table_options: default_table_options:
charset: utf8mb4 charset: utf8mb4

2
config/packages/monolog.yaml

@ -24,6 +24,8 @@ when@dev:
path: "php://stderr" path: "php://stderr"
# Min level info: debug stays out of stderr (file only). # Min level info: debug stays out of stderr (file only).
level: info level: info
# User deprecations (vendor) still land in var/log/…; avoid duplicating to Docker stderr.
channels: [ "!event", "!deprecation" ]
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false

1
config/services.yaml

@ -41,6 +41,7 @@ services:
$articleRelayUrls: '%article_relays%' $articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%' $profileRelayUrls: '%profile_relays%'
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
$relayRequestTimeoutSec: '%nostr_relay_request_timeout_sec%'
App\Service\ArticleCommentThreadLoader: App\Service\ArticleCommentThreadLoader:
arguments: arguments:
$appCachePool: '@cache.replies' $appCachePool: '@cache.replies'

3
config/unfold.yaml

@ -1,6 +1,9 @@
# Site identity and theme (see assets/theme/local/ to override default assets). # Site identity and theme (see assets/theme/local/ to override default assets).
parameters: 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' name: 'Nostr, Curated Thoughtfully'
short_name: 'Imwald Blog' 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.' 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.'

22
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

5
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' docker compose exec -T php php bin/console articles:get -- '-2 month' 'now'
echo "==> app:prewarm" 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." echo "Done."

160
src/Command/ArticleHighlightsAuditCommand.php

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\ArticleHighlight;
use App\Repository\ArticleHighlightRepository;
use App\Repository\ArticleRepository;
use App\Service\ArticleBodyHighlightInjector;
use App\Util\CommonMark\Converter;
use League\CommonMark\Exception\CommonMarkException;
use swentel\nostr\Key\Key;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Run inside the app container, e.g.:
* `php bin/console app:article-highlights-audit bitcoin-is-time --npub=npub1…`
*/
#[AsCommand(
name: 'app:article-highlights-audit',
description: 'Show how many kind-9802 rows match the article and how many <mark> 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: <info>'.$expectedNpub.'</info>');
$io->writeln('Article id: <info>'.(string) $article->getId().'</info> · kind: <info>'.
($article->getKind()?->value ?? 'null').'</info>');
$highlights = $this->articleHighlightRepository->findByArticle($article);
$io->writeln('Rows from <comment>findByArticle</comment>: <info>'.\count($highlights).'</info>');
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 <comment>all highlights together</comment> (duplicates = same passage): <info>'.\count($injected).'</info>');
$io->writeln('<mark class="user-highlight__marker"> count in body: <info>'.$markCount.'</info>');
$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 <mark> 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;
}
}

10
src/Command/PrewarmCommand.php

@ -80,6 +80,11 @@ final class PrewarmCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$this->disableCliExecutionTimeLimit(); $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); $io = new SymfonyStyle($input, $output);
$keys = new Key(); $keys = new Key();
@ -181,6 +186,10 @@ final class PrewarmCommand extends Command
$io->note('Skipping magazine (--no-magazine).'); $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)'); $io->section('Long-form in DB (category `a` tags — refresh from Nostr)');
try { try {
$n = $this->magazineContent->ingestLongformForAllMagazineCategories(); $n = $this->magazineContent->ingestLongformForAllMagazineCategories();
@ -224,7 +233,6 @@ final class PrewarmCommand extends Command
$io->warning('Featured author reconcile failed: '.$e->getMessage()); $io->warning('Featured author reconcile failed: '.$e->getMessage());
} }
// MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata.
$this->disableCliExecutionTimeLimit(); $this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) { if (!$input->getOption('no-deletions')) {

8
src/Controller/ArticleController.php

@ -12,11 +12,10 @@ use App\Form\EditorType;
use App\Service\ArticleCommentThreadLoader; use App\Service\ArticleCommentThreadLoader;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\CacheService; use App\Service\CacheService;
use App\Nostr\Nip19Codec;
use App\Util\CommonMark\Converter; use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
@ -253,15 +252,14 @@ class ArticleController extends AbstractController
* @throws \Exception * @throws \Exception
*/ */
#[Route('/article/{naddr}', name: 'article-naddr')] #[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') { if ($decoded->type !== 'naddr') {
throw new \Exception('Invalid naddr'); throw new \Exception('Invalid naddr');
} }
/** @var NAddr $data */
$data = $decoded->data; $data = $decoded->data;
$slug = $data->identifier; $slug = $data->identifier;
$relays = $data->relays; $relays = $data->relays;

12
src/Controller/EventController.php

@ -4,13 +4,12 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Nostr\Nip19Codec;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\NostrLinkParser; use App\Service\NostrLinkParser;
use App\Service\NostrShareMenuBuilder; use App\Service\NostrShareMenuBuilder;
use App\Service\CacheService; use App\Service\CacheService;
use Exception; use Exception;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -28,6 +27,7 @@ class EventController extends AbstractController
public function index( public function index(
$nevent, $nevent,
Request $request, Request $request,
Nip19Codec $nip19,
NostrClient $nostrClient, NostrClient $nostrClient,
CacheService $cacheService, CacheService $cacheService,
NostrLinkParser $nostrLinkParser, NostrLinkParser $nostrLinkParser,
@ -38,11 +38,9 @@ class EventController extends AbstractController
try { try {
// Decode nevent - nevent1... is a NIP-19 encoded event identifier // 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)]); $logger->info('Decoded event', ['decoded' => json_encode($decoded)]);
// Get the event using the event ID
/** @var Data $data */
$data = $decoded->data; $data = $decoded->data;
$logger->info('Event data', ['data' => json_encode($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 // Sort which event type this is using $data->type
switch ($decoded->type) { switch ($decoded->type) {
case 'note': case 'note':
// Handle note (regular event) $eventHex = (string) ($data->data ?? '');
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
if (!\is_array($relays)) { if (!\is_array($relays)) {
$relays = []; $relays = [];
} }
$event = $nostrClient->getEventById($data->identifier, $relays); $event = $nostrClient->getEventById($eventHex, $relays);
break; break;
case 'nprofile': case 'nprofile':

8
src/Nostr/Nip19Addressable.php

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Nostr; namespace App\Nostr;
use App\Entity\Event; use App\Entity\Event;
use nostriphant\NIP19\Bech32;
/** /**
* NIP-33 / NIP-19 helpers: naddr for parameterized replaceable events (kind:pubkey:d). * 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.'); throw new \InvalidArgumentException('Invalid pubkey hex for naddr.');
} }
return (string) Bech32::naddr( return (new Nip19Codec())->encodeNaddr($kind, $pubkeyHex, $dIdentifier, $relays);
kind: $kind,
pubkey: $pubkeyHex,
identifier: $dIdentifier,
relays: $relays,
);
} }
} }

120
src/Nostr/Nip19Codec.php

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use swentel\nostr\Event\Event;
use swentel\nostr\Nip19\Nip19Helper;
/**
* NIP-19 encode/decode using swentel/nostr-php, output-shaped for code that previously used nostriphant\NIP19\Bech32
* (objects with {@see $type} and {@see $data} for decoded entities).
*/
final class Nip19Codec
{
private Nip19Helper $nip19Helper;
public function __construct(?Nip19Helper $nip19Helper = null)
{
$this->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);
}
}

3
src/Repository/ArticleHighlightRepository.php

@ -69,7 +69,8 @@ class ArticleHighlightRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('h') $qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a') ->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') ->andWhere('a.slug = :slug')
->setParameter('pubkey', $pubkey) ->setParameter('pubkey', $pubkey)
->setParameter('slug', $slug) ->setParameter('slug', $slug)

56
src/Service/ArticleBodyHighlightInjector.php

@ -100,27 +100,44 @@ final class ArticleBodyHighlightInjector
libxml_use_internal_errors($prev); libxml_use_internal_errors($prev);
libxml_clear_errors(); 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(
'<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>',
\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); $xp = new DOMXPath($this->dom);
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]'); $nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]');
if (false !== $nodes && $nodes->length > 0) { if (false !== $nodes && $nodes->length > 0) {
$first = $nodes->item(0); $first = $nodes->item(0);
$this->root = $first instanceof DOMElement ? $first : null;
} else { return $first instanceof DOMElement ? $first : null;
$this->root = null;
}
if (null === $this->root) {
$de = $this->dom->documentElement;
if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) {
$this->root = $de;
}
} }
if (null === $this->root) { $de = $this->dom->documentElement;
$this->root = $this->findFirstDivById(self::ROOT_ID); if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) {
return $de;
} }
if (null === $this->root) { $d = $this->findFirstDivById(self::ROOT_ID);
$this->root = $this->findElementByIdFallback(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 private function findFirstDivById(string $id): ?DOMElement
@ -385,11 +402,14 @@ final class ArticleBodyHighlightInjector
*/ */
private function injectionNeedleBasesInPriority(ArticleHighlight $h): array private function injectionNeedleBasesInPriority(ArticleHighlight $h): array
{ {
$c = \trim($h->getContent()); $rawContent = (string) $h->getContent();
$tags = $h->getTags(); $tags = $h->getTags();
$ctx = \trim(HighlightEventTags::contextFromTags($tags)); $c = HighlightEventTags::trimNostrText($rawContent);
$fullPassage = \trim(HighlightEventTags::fullPassageForHighlightDisplay($c, $tags)); $ctx = HighlightEventTags::trimNostrText(HighlightEventTags::contextFromTags($tags));
$tq = \trim(HighlightEventTags::textquoteselectorPassageFromTags($tags)); $fullPassage = HighlightEventTags::trimNostrText(
HighlightEventTags::fullPassageForHighlightDisplay($rawContent, $tags)
);
$tq = HighlightEventTags::trimNostrText(HighlightEventTags::textquoteselectorPassageFromTags($tags));
$out = []; $out = [];
$seen = []; $seen = [];
// NIP-84: `context` = full quote; `content` = highlighted span. Missing/empty `context` is // NIP-84: `context` = full quote; `content` = highlighted span. Missing/empty `context` is

3
src/Service/HighlightSyncService.php

@ -73,7 +73,8 @@ final class HighlightSyncService
} }
$excerpt = HighlightEventTags::excerptForFeed($content, $tags); $excerpt = HighlightEventTags::excerptForFeed($content, $tags);
if ($excerpt === '') { 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]); $row = $this->highlightRepository->findOneBy(['eventId' => $eid]);

18
src/Service/MagazineRefresher.php

@ -59,8 +59,11 @@ final class MagazineRefresher
$dTag = (string) $this->params->get('d_tag'); $dTag = (string) $this->params->get('d_tag');
$preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs); $preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs);
// Allow enough PHP wall time for a slow root fetch plus the full category-phase budget. // Do not cap max_execution_time here. A previous design used 2×budget+30s (capped 210) which
$this->applyExecutionTimeCap(2 * $budgetSeconds); // 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'); $defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay); $relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
@ -250,15 +253,10 @@ final class MagazineRefresher
$this->appCache->save($item); $this->appCache->save($item);
} }
/** private function ensureUnlimitedPhpExecutionTime(): void
* 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
{ {
$sec = max(30, min(700, $budgetSeconds + 30)); @\set_time_limit(0);
@set_time_limit($sec); @\ini_set('max_execution_time', '0');
@ini_set('max_execution_time', (string) $sec);
} }
/** /**

21
src/Service/NostrClient.php

@ -10,7 +10,6 @@ use App\Enum\KindsEnum;
use App\Factory\ArticleFactory; use App\Factory\ArticleFactory;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
@ -29,9 +28,6 @@ use Symfony\Contracts\Cache\ItemInterface;
class NostrClient 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. */ /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0; private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
/** Soft wall-time for parallel discussion collection before returning partial results. */ /** 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; private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3;
/** /**
* {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC * {@see sendArticleDiscussionToRelaysSequential} visits relays one after another
* each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts. * (~{@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; private const MAX_SEQUENTIAL_RELAY_URLS = 3;
@ -75,6 +72,7 @@ class NostrClient
/** /**
* @param list<string> $articleRelayUrls extra relays for the default set (default_relay is always first) * @param list<string> $articleRelayUrls extra relays for the default set (default_relay is always first)
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see profileMetadataQueryRelayUrlList()}) * @param list<string> $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( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
@ -87,6 +85,7 @@ class NostrClient
private readonly array $profileRelayUrls, private readonly array $profileRelayUrls,
private readonly CacheInterface $relayQueryCache, private readonly CacheInterface $relayQueryCache,
private readonly string $projectDir, private readonly string $projectDir,
private readonly int $relayRequestTimeoutSec = 12,
) { ) {
$this->defaultRelaySet = $this->buildArticleRelaySet(); $this->defaultRelaySet = $this->buildArticleRelaySet();
} }
@ -249,7 +248,7 @@ class NostrClient
$request = new Request($relaySet, $requestMessage); $request = new Request($relaySet, $requestMessage);
// 1.9.4+: Request::setTimeout() drives getResponseFromRelay(). Older: only WebSocket client on Relay. // 1.9.4+: Request::setTimeout() drives getResponseFromRelay(). Older: only WebSocket client on Relay.
if (method_exists($request, 'setTimeout')) { if (method_exists($request, 'setTimeout')) {
$request->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC); $request->setTimeout($this->relayRequestTimeoutSec);
} else { } else {
$this->applyRelaySocketTimeoutToSet($relaySet); $this->applyRelaySocketTimeoutToSet($relaySet);
} }
@ -265,7 +264,7 @@ class NostrClient
foreach ($relaySet->getRelays() as $relay) { foreach ($relaySet->getRelays() as $relay) {
$client = $relay->getClient(); $client = $relay->getClient();
if (method_exists($client, 'setTimeout')) { 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'; $worker = $this->projectDir.'/bin/nostr_relay_request_worker.php';
$phpBinary = (new PhpExecutableFinder())->find() ?: '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); $rawPayload = serialize($requestMessage);
$tmp = tempnam(sys_get_temp_dir(), 'nrq_'); $tmp = tempnam(sys_get_temp_dir(), 'nrq_');
@ -1611,7 +1611,7 @@ class NostrClient
null, null,
(float) $timeout (float) $timeout
); );
$p->start(); $p->start(null, $workerTimeoutEnv);
$procs[$wss] = $p; $procs[$wss] = $p;
} }
@ -2530,7 +2530,6 @@ class NostrClient
// Descriptor is an stdClass with properties: type and decoded // Descriptor is an stdClass with properties: type and decoded
if (is_object($descriptor) && isset($descriptor->type, $descriptor->decoded)) { if (is_object($descriptor) && isset($descriptor->type, $descriptor->decoded)) {
// construct a request from the descriptor to fetch the event // construct a request from the descriptor to fetch the event
/** @var Data $ata */
$data = json_decode($descriptor->decoded); $data = json_decode($descriptor->decoded);
if (!\is_object($data)) { if (!\is_object($data)) {
$this->logger->error('Invalid descriptor decoded JSON', ['descriptor' => $descriptor]); $this->logger->error('Invalid descriptor decoded JSON', ['descriptor' => $descriptor]);

11
src/Service/NostrLinkParser.php

@ -2,7 +2,7 @@
namespace App\Service; namespace App\Service;
use nostriphant\NIP19\Bech32; use App\Nostr\Nip19Codec;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
readonly class NostrLinkParser readonly class NostrLinkParser
@ -12,7 +12,8 @@ readonly class NostrLinkParser
private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i'; private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i';
public function __construct( 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)) { if (preg_match(self::NOSTR_LINK_PATTERN, $url, $nostrMatch)) {
$nostrId = $nostrMatch[1]; $nostrId = $nostrMatch[1];
try { try {
$decoded = new Bech32($nostrId); $decoded = $this->nip19->decode($nostrId);
$nostrType = $decoded->type; $nostrType = $decoded->type;
$nostrData = $decoded->data; $nostrData = $decoded->data;
} catch (\Exception $e) { } catch (\Exception $e) {
@ -150,7 +151,7 @@ readonly class NostrLinkParser
$position = $match[0][1]; $position = $match[0][1];
// This check will be handled in parseLinks by sorting and merging // This check will be handled in parseLinks by sorting and merging
try { try {
$decoded = new Bech32($identifier); $decoded = $this->nip19->decode($identifier);
$links[] = [ $links[] = [
'type' => $decoded->type, 'type' => $decoded->type,
'identifier' => $identifier, 'identifier' => $identifier,
@ -179,7 +180,7 @@ readonly class NostrLinkParser
$position = $match[0][1]; $position = $match[0][1];
$identifier = ltrim($raw, '@'); $identifier = ltrim($raw, '@');
try { try {
$decoded = new Bech32($identifier); $decoded = $this->nip19->decode($identifier);
if (!\in_array($decoded->type, ['naddr', 'nevent'], true)) { if (!\in_array($decoded->type, ['naddr', 'nevent'], true)) {
continue; continue;
} }

47
src/Service/NostrShareMenuBuilder.php

@ -8,8 +8,8 @@ use App\Dto\NostrShareMenuContext;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event; use App\Entity\Event;
use App\Nostr\Nip19Addressable; use App\Nostr\Nip19Addressable;
use App\Nostr\Nip19Codec;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use nostriphant\NIP19\Bech32;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -42,12 +42,7 @@ final class NostrShareMenuBuilder
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); $naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) $neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex))
? (string) Bech32::nevent( ? $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind)
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
)
: null; : null;
return new NostrShareMenuContext( return new NostrShareMenuContext(
@ -58,12 +53,7 @@ final class NostrShareMenuBuilder
); );
} }
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = (string) Bech32::nevent( $rebuilt = $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind);
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
);
return new NostrShareMenuContext( return new NostrShareMenuContext(
$npub, $npub,
@ -138,6 +128,7 @@ final class NostrShareMenuBuilder
public function __construct( public function __construct(
private readonly MagazineIndexStore $magazineIndexStore, private readonly MagazineIndexStore $magazineIndexStore,
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
private readonly Nip19Codec $nip19,
#[Autowire('%npub%')] #[Autowire('%npub%')]
private readonly string $siteNpub, private readonly string $siteNpub,
#[Autowire('%d_tag%')] #[Autowire('%d_tag%')]
@ -213,12 +204,7 @@ final class NostrShareMenuBuilder
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$eid = strtolower((string) ($article->getEventId() ?? '')); $eid = strtolower((string) ($article->getEventId() ?? ''));
$nevent = (64 === \strlen($eid) && ctype_xdigit($eid)) $nevent = (64 === \strlen($eid) && ctype_xdigit($eid))
? (string) Bech32::nevent( ? $this->nip19->encodeNevent($eid, [], $pk, $kind)
id: $eid,
relays: [],
author: $pk,
kind: $kind,
)
: null; : null;
return new NostrShareMenuContext( return new NostrShareMenuContext(
@ -270,7 +256,7 @@ final class NostrShareMenuBuilder
return $this->siteWithRootMenu(); return $this->siteWithRootMenu();
} }
try { try {
$decoded = new Bech32($nevent); $decoded = $this->nip19->decode($nevent);
} catch (\Throwable) { } catch (\Throwable) {
return $this->siteWithRootMenu(); return $this->siteWithRootMenu();
} }
@ -291,12 +277,7 @@ final class NostrShareMenuBuilder
$relays = $decoded->data->relays ?? []; $relays = $decoded->data->relays ?? [];
$relays = \is_array($relays) ? $relays : []; $relays = \is_array($relays) ? $relays : [];
if ($authorHex !== null) { if ($authorHex !== null) {
$rebuilt = (string) Bech32::nevent( $rebuilt = $this->nip19->encodeNevent($eventId, $relays, $authorHex, $kind);
id: $eventId,
relays: $relays,
author: $authorHex,
kind: $kind,
);
return new NostrShareMenuContext( return new NostrShareMenuContext(
$this->nostrKey()->convertPublicKeyToBech32($authorHex), $this->nostrKey()->convertPublicKeyToBech32($authorHex),
@ -342,12 +323,7 @@ final class NostrShareMenuBuilder
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk); $npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); $naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$neventForRev = (string) Bech32::nevent( $neventForRev = $this->nip19->encodeNevent($id, [], $pk, $kind);
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext( return new NostrShareMenuContext(
$npub, $npub,
@ -356,12 +332,7 @@ final class NostrShareMenuBuilder
$this->feedJumble($naddr), $this->feedJumble($naddr),
); );
} }
$nevent = (string) Bech32::nevent( $nevent = $this->nip19->encodeNevent($id, [], $pk, $kind);
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext( return new NostrShareMenuContext(
$npub, $npub,

9
src/Util/CommonMark/Converter.php

@ -2,6 +2,7 @@
namespace App\Util\CommonMark; namespace App\Util\CommonMark;
use App\Nostr\Nip19Codec;
use App\Service\CacheService; use App\Service\CacheService;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
@ -25,9 +26,9 @@ use League\CommonMark\Renderer\HtmlDecorator;
readonly class Converter readonly class Converter
{ {
public function __construct( 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 TableExtension());
$environment->addExtension(new StrikethroughExtension()); $environment->addExtension(new StrikethroughExtension());
// create a custom extension, that handles nostr mentions // 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 SmartPunctExtension());
$environment->addExtension(new EmbedExtension()); $environment->addExtension(new EmbedExtension());
$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content'])); $environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content']));

15
src/Util/CommonMark/NostrSchemeExtension/NostrBareBech32Parser.php

@ -4,18 +4,20 @@ declare(strict_types=1);
namespace App\Util\CommonMark\NostrSchemeExtension; namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext; 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. * Matches bare or @-prefixed naddr1 / nevent1 (NIP-19), so they render like nostr:… links.
*/ */
final class NostrBareBech32Parser implements InlineParserInterface final class NostrBareBech32Parser implements InlineParserInterface
{ {
public function __construct(
private readonly Nip19Codec $nip19,
) {
}
public function getMatchDefinition(): InlineParserMatch public function getMatchDefinition(): InlineParserMatch
{ {
return InlineParserMatch::regex('(?:@)?(?:naddr1|nevent1)[0-9a-z]+'); return InlineParserMatch::regex('(?:@)?(?:naddr1|nevent1)[0-9a-z]+');
@ -32,16 +34,14 @@ final class NostrBareBech32Parser implements InlineParserInterface
} }
try { try {
$decoded = new Bech32($bech); $decoded = $this->nip19->decode($bech);
} catch (\Throwable) { } catch (\Throwable) {
return false; return false;
} }
if ($decoded->type === 'naddr') { if ($decoded->type === 'naddr') {
/** @var NAddr $data */
$data = $decoded->data; $data = $decoded->data;
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
// NIP-19 naddr TLVs include author pubkey and kind; normalize like `nevent` if TLVs are missing.
$author = $data->pubkey ?? ''; $author = $data->pubkey ?? '';
$kind = (int) ($data->kind ?? 0); $kind = (int) ($data->kind ?? 0);
$inlineContext->getContainer()->appendChild(new NostrSchemeData( $inlineContext->getContainer()->appendChild(new NostrSchemeData(
@ -52,10 +52,9 @@ final class NostrBareBech32Parser implements InlineParserInterface
$kind $kind
)); ));
} elseif ($decoded->type === 'nevent') { } elseif ($decoded->type === 'nevent') {
/** @var NEvent $data */
$data = $decoded->data; $data = $decoded->data;
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
$author = $data->author ?? $data->pubkey ?? ''; $author = (string) ($data->author ?? $data->pubkey ?? '');
$inlineContext->getContainer()->appendChild(new NostrSchemeData( $inlineContext->getContainer()->appendChild(new NostrSchemeData(
'nevent', 'nevent',
$bech, $bech,

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

@ -2,14 +2,18 @@
namespace App\Util\CommonMark\NostrSchemeExtension; namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Node\Node; use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement; use League\CommonMark\Util\HtmlElement;
use nostriphant\NIP19\Bech32;
class NostrEventRenderer implements NodeRendererInterface class NostrEventRenderer implements NodeRendererInterface
{ {
public function __construct(
private readonly Nip19Codec $nip19,
) {
}
public function render(Node $node, ChildNodeRendererInterface $childRenderer) public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{ {
if (!($node instanceof NostrSchemeData)) { if (!($node instanceof NostrSchemeData)) {
@ -28,7 +32,7 @@ class NostrEventRenderer implements NodeRendererInterface
{ {
$bech = $node->getSpecial(); $bech = $node->getSpecial();
try { try {
$decoded = new Bech32($bech); $decoded = $this->nip19->decode($bech);
$payload = json_decode(json_encode($decoded->data), true, 512, JSON_THROW_ON_ERROR); $payload = json_decode(json_encode($decoded->data), true, 512, JSON_THROW_ON_ERROR);
$decodedJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $decodedJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (\Throwable) { } catch (\Throwable) {

13
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php

@ -2,6 +2,7 @@
namespace App\Util\CommonMark\NostrSchemeExtension; namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use App\Service\CacheService; use App\Service\CacheService;
use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface; use League\CommonMark\Extension\ExtensionInterface;
@ -9,19 +10,21 @@ use League\CommonMark\Extension\ExtensionInterface;
class NostrSchemeExtension implements 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 public function register(EnvironmentBuilderInterface $environment): void
{ {
$environment $environment
->addInlineParser(new NostrBareBech32Parser(), 202) ->addInlineParser(new NostrBareBech32Parser($this->nip19), 202)
->addInlineParser(new NostrMentionParser($this->cacheService), 200) ->addInlineParser(new NostrMentionParser($this->cacheService), 200)
->addInlineParser(new NostrSchemeParser(), 199) ->addInlineParser(new NostrSchemeParser($this->nip19), 199)
->addInlineParser(new NostrRawNpubParser($this->cacheService), 198) ->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) ->addRenderer(NostrMentionLink::class, new NostrMentionRenderer(), 1)
; ;
} }

29
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -2,19 +2,16 @@
namespace App\Util\CommonMark\NostrSchemeExtension; namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Nostr\Nip19Codec;
use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext; 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 class NostrSchemeParser implements InlineParserInterface
{ {
public function __construct(
public function __construct() private readonly Nip19Codec $nip19,
{ ) {
} }
public function getMatchDefinition(): InlineParserMatch public function getMatchDefinition(): InlineParserMatch
@ -32,35 +29,29 @@ class NostrSchemeParser implements InlineParserInterface
$bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX" $bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX"
try { try {
$decoded = new Bech32($bechEncoded); $decoded = $this->nip19->decode($bechEncoded);
switch ($decoded->type) { switch ($decoded->type) {
case 'npub': 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)); $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded));
break; break;
case 'nprofile': case 'nprofile':
/** @var NProfile $decodedProfile */
$decodedProfile = $decoded->data; $decodedProfile = $decoded->data;
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey)); $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey));
break; break;
case 'nevent': case 'nevent':
/** @var NEvent $decodedNpub */
$decodedEvent = $decoded->data; $decodedEvent = $decoded->data;
$eventId = $decodedEvent->id; $relays = $decodedEvent->relays ?? [];
$relays = $decodedEvent->relays;
$author = $decodedEvent->author; $author = $decodedEvent->author;
$kind = $decodedEvent->kind; $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; break;
case 'naddr': case 'naddr':
/** @var NAddr $decodedNpub */
$decodedEvent = $decoded->data; $decodedEvent = $decoded->data;
$identifier = $decodedEvent->identifier; $relays = $decodedEvent->relays ?? [];
$pubkey = $decodedEvent->pubkey; $pubkey = $decodedEvent->pubkey;
$kind = $decodedEvent->kind; $kind = (int) ($decodedEvent->kind ?? 0);
$relays = $decodedEvent->relays; $inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, \is_array($relays) ? $relays : [], (string) $pubkey, $kind));
$inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, $relays, $pubkey, $kind));
break; break;
case 'nrelay': case 'nrelay':
// deprecated // deprecated

31
src/Util/HighlightEventTags.php

@ -60,6 +60,24 @@ final class HighlightEventTags
return $out; 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). * 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 public static function excerptForFeed(string $content, array $tags): string
{ {
$c = \trim((string) $content); $raw = (string) $content;
if ($c !== '') { if ($raw !== '') {
return \mb_substr($c, 0, 400); $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 !== '') { if ($ctx !== '') {
return \mb_substr($ctx, 0, 400); return \mb_substr($ctx, 0, 400);
} }
$tq = \trim(self::excerptFromTextquoteselectorTags($tags)); $tq = self::trimNostrText(self::excerptFromTextquoteselectorTags($tags));
return $tq !== '' ? $tq : ''; return $tq !== '' ? $tq : '';
} }

Loading…
Cancel
Save