26 changed files with 830 additions and 80 deletions
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Dto; |
||||
|
||||
/** |
||||
* Nostr "⋯" share menu: copy npub; copy nevent or naddr (Jumble uses the same bech in /feed/notes/…). |
||||
* Addressable (NIP-33) long-form / index events: prefer naddr; one-off stateless events: nevent. |
||||
*/ |
||||
final class NostrShareMenuContext |
||||
{ |
||||
public function __construct( |
||||
/** NIP-19 npub. Null only in rare fallbacks. */ |
||||
public ?string $npub, |
||||
public ?string $neventBech32, |
||||
public ?string $naddrBech32, |
||||
public string $jumbleHref, |
||||
) { |
||||
} |
||||
} |
||||
@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
|
||||
<?php |
||||
|
||||
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). |
||||
*/ |
||||
final class Nip19Addressable |
||||
{ |
||||
/** |
||||
* NIP-33 replaceable kinds (30000–39999) use a `d` tag; encode as naddr, not nevent, for clients. |
||||
*/ |
||||
public static function isParameterizedReplaceableKind(int $kind): bool |
||||
{ |
||||
return $kind >= 30_000 && $kind < 40_000; |
||||
} |
||||
|
||||
/** |
||||
* @param array<int, mixed> $tagRows |
||||
*/ |
||||
public static function dTagFromTagRows(array $tagRows): ?string |
||||
{ |
||||
foreach ($tagRows as $row) { |
||||
if (!\is_array($row) && !\is_object($row)) { |
||||
continue; |
||||
} |
||||
if (\is_object($row)) { |
||||
$row = (array) $row; |
||||
} |
||||
$row = array_values($row); |
||||
if ($row === []) { |
||||
continue; |
||||
} |
||||
if (strtolower((string) ($row[0] ?? '')) === 'd' && isset($row[1])) { |
||||
$d = (string) $row[1]; |
||||
if ($d !== '') { |
||||
return $d; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public static function dTagFromEventEntity(Event $e): ?string |
||||
{ |
||||
return self::dTagFromTagRows($e->getTags()); |
||||
} |
||||
|
||||
public static function naddrBech32( |
||||
int $kind, |
||||
string $pubkeyHex, |
||||
string $dIdentifier, |
||||
array $relays = [], |
||||
): string { |
||||
$pubkeyHex = strtolower($pubkeyHex); |
||||
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { |
||||
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.'); |
||||
} |
||||
|
||||
return (string) Bech32::naddr( |
||||
kind: $kind, |
||||
pubkey: $pubkeyHex, |
||||
identifier: $dIdentifier, |
||||
relays: $relays, |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Article; |
||||
use swentel\nostr\Key\Key; |
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; |
||||
|
||||
/** |
||||
* Canonical /p/{npub}/d/{slug} links for long-form and helpers for templates. |
||||
*/ |
||||
final class NostrPathHelper |
||||
{ |
||||
public function __construct( |
||||
private readonly UrlGeneratorInterface $router, |
||||
) { |
||||
} |
||||
|
||||
public function npubFromPubkeyHex(string $pubkeyHex): string |
||||
{ |
||||
return (new Key())->convertPublicKeyToBech32($pubkeyHex); |
||||
} |
||||
|
||||
public function articlePath(Article $article): string |
||||
{ |
||||
$slug = (string) ($article->getSlug() ?? ''); |
||||
if ($slug === '' || $article->getPubkey() === null) { |
||||
return ''; |
||||
} |
||||
$npub = $this->npubFromPubkeyHex((string) $article->getPubkey()); |
||||
|
||||
return $this->router->generate('article', [ |
||||
'npub' => $npub, |
||||
'slug' => $slug, |
||||
]); |
||||
} |
||||
|
||||
public function articleAbsoluteUrl(Article $article): string |
||||
{ |
||||
$slug = (string) ($article->getSlug() ?? ''); |
||||
if ($slug === '' || $article->getPubkey() === null) { |
||||
return ''; |
||||
} |
||||
|
||||
return $this->router->generate('article', [ |
||||
'npub' => $this->npubFromPubkeyHex((string) $article->getPubkey()), |
||||
'slug' => $slug, |
||||
], UrlGeneratorInterface::ABSOLUTE_URL); |
||||
} |
||||
} |
||||
@ -0,0 +1,348 @@
@@ -0,0 +1,348 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Dto\NostrShareMenuContext; |
||||
use App\Entity\Article; |
||||
use App\Entity\Event; |
||||
use App\Nostr\Nip19Addressable; |
||||
use App\Repository\ArticleRepository; |
||||
use nostriphant\NIP19\Bech32; |
||||
use swentel\nostr\Key\Key; |
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
|
||||
/** |
||||
* Resolves the header Nostr share menu (npub; naddr for addressables, else nevent; Jumble /feed/notes/…). |
||||
*/ |
||||
final class NostrShareMenuBuilder |
||||
{ |
||||
public const string ATTR_NPUB = 'nostr_share_npub'; |
||||
|
||||
public const string ATTR_NEVENT_BECH32 = 'nostr_share_nevent_bech32'; |
||||
|
||||
public const string ATTR_NADDR_BECH32 = 'nostr_share_naddr_bech32'; |
||||
|
||||
public static function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void |
||||
{ |
||||
$pubkeyHex = strtolower((string) ($event->pubkey ?? '')); |
||||
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { |
||||
return; |
||||
} |
||||
$key = new Key(); |
||||
$request->attributes->set(self::ATTR_NPUB, $key->convertPublicKeyToBech32($pubkeyHex)); |
||||
$kind = (int) ($event->kind ?? 0); |
||||
$d = self::dTagFromWireEvent($event); |
||||
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { |
||||
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); |
||||
$request->attributes->set(self::ATTR_NADDR_BECH32, $naddr); |
||||
|
||||
return; |
||||
} |
||||
$eventIdHex = strtolower((string) ($event->id ?? '')); |
||||
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { |
||||
$rebuilt = (string) Bech32::nevent( |
||||
id: $eventIdHex, |
||||
relays: $relayHints, |
||||
author: $pubkeyHex, |
||||
kind: $kind, |
||||
); |
||||
$request->attributes->set(self::ATTR_NEVENT_BECH32, $rebuilt); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param list<mixed>|\ArrayObject<int, mixed> $event->tags |
||||
*/ |
||||
private static function dTagFromWireEvent(object $event): ?string |
||||
{ |
||||
if (!isset($event->tags)) { |
||||
return null; |
||||
} |
||||
$rows = $event->tags; |
||||
if ($rows instanceof \ArrayObject) { |
||||
$rows = $rows->getArrayCopy(); |
||||
} |
||||
if (!\is_array($rows)) { |
||||
return null; |
||||
} |
||||
$norm = array_values( |
||||
array_map( |
||||
static function ($r) { |
||||
if (!\is_array($r) && !\is_object($r)) { |
||||
return $r; |
||||
} |
||||
if (\is_object($r)) { |
||||
$r = (array) $r; |
||||
} |
||||
|
||||
return $r; |
||||
}, |
||||
$rows |
||||
) |
||||
); |
||||
|
||||
return Nip19Addressable::dTagFromTagRows($norm); |
||||
} |
||||
|
||||
public function __construct( |
||||
private readonly MagazineIndexStore $magazineIndexStore, |
||||
private readonly ArticleRepository $articleRepository, |
||||
#[Autowire('%npub%')] |
||||
private readonly string $siteNpub, |
||||
#[Autowire('%d_tag%')] |
||||
private readonly string $rootDTag, |
||||
#[Autowire('%jumble_profile_users_base%')] |
||||
private readonly string $jumbleProfileUsersBase, |
||||
#[Autowire('%jumble_feed_notes_base%')] |
||||
private readonly string $jumbleFeedNotesBase, |
||||
) { |
||||
} |
||||
|
||||
private function nostrKey(): Key |
||||
{ |
||||
return new Key(); |
||||
} |
||||
|
||||
public function buildForRequest(Request $request): ?NostrShareMenuContext |
||||
{ |
||||
if ($request->isXmlHttpRequest() || 'xmlhttprequest' === strtolower((string) $request->headers->get('X-Requested-With'))) { |
||||
return null; |
||||
} |
||||
if ($request->attributes->getBoolean('_embed')) { |
||||
return null; |
||||
} |
||||
$route = (string) $request->attributes->get('_route', ''); |
||||
if (str_ends_with($route, 'fragment') || str_starts_with($request->getPathInfo(), '/fragment/')) { |
||||
return null; |
||||
} |
||||
if ('' === $route) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
|
||||
return match ($route) { |
||||
'home' => $this->siteWithRootMenu(), |
||||
'article' => $this->forArticleNpubD( |
||||
(string) $request->attributes->get('npub', ''), |
||||
(string) $request->attributes->get('slug', ''), |
||||
), |
||||
'author-profile' => $this->forAuthorProfile($request->attributes->get('npub', '')), |
||||
'nevent' => $this->forNevent($request, (string) $request->attributes->get('nevent', '')), |
||||
'magazine-category' => $this->forCategory($request->attributes->get('slug', '')), |
||||
'articles', 'featured_authors', 'search', 'article-preview', 'article-preview-event', 'editor-create', 'editor-edit' => $this->siteWithRootMenu(), |
||||
default => $this->siteWithRootMenu(), |
||||
}; |
||||
} |
||||
|
||||
private function forArticleNpubD(string $npub, string $slug): NostrShareMenuContext |
||||
{ |
||||
if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
$list = $this->articleRepository->findBy(['slug' => $slug], ['createdAt' => 'DESC'], 1); |
||||
$article = $list[0] ?? null; |
||||
if ($article === null) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
if ($this->nostrKey()->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
|
||||
return $this->fromArticle($article); |
||||
} |
||||
|
||||
private function fromArticle(Article $article): NostrShareMenuContext |
||||
{ |
||||
$npub = $this->nostrKey()->convertPublicKeyToBech32((string) $article->getPubkey()); |
||||
$kind = (int) ($article->getKind()?->value ?? 30023); |
||||
$d = (string) ($article->getSlug() ?? ''); |
||||
if ($d === '') { |
||||
return new NostrShareMenuContext( |
||||
$npub, |
||||
null, |
||||
null, |
||||
$this->profileJumbleUrl($npub), |
||||
); |
||||
} |
||||
$pk = strtolower((string) $article->getPubkey()); |
||||
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$npub, |
||||
null, |
||||
$naddr, |
||||
$this->feedJumble($naddr), |
||||
); |
||||
} |
||||
|
||||
private function forAuthorProfile(mixed $npubParam): NostrShareMenuContext |
||||
{ |
||||
$npub = (string) $npubParam; |
||||
if ($npub === '' || !str_starts_with($npub, 'npub1')) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
|
||||
return new NostrShareMenuContext( |
||||
$npub, |
||||
null, |
||||
null, |
||||
$this->profileJumbleUrl($npub), |
||||
); |
||||
} |
||||
|
||||
private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext |
||||
{ |
||||
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NADDR_BECH32)) { |
||||
$naddr = (string) $request->attributes->get(self::ATTR_NADDR_BECH32); |
||||
$np = (string) $request->attributes->get(self::ATTR_NPUB); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$np, |
||||
null, |
||||
$naddr, |
||||
$this->feedJumble($naddr), |
||||
); |
||||
} |
||||
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NEVENT_BECH32)) { |
||||
$nb = (string) $request->attributes->get(self::ATTR_NEVENT_BECH32); |
||||
$np = (string) $request->attributes->get(self::ATTR_NPUB); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$np, |
||||
$nb, |
||||
null, |
||||
$this->feedJumble($nb), |
||||
); |
||||
} |
||||
|
||||
$nevent = $neventFromRoute; |
||||
if ($nevent === '' || !str_starts_with($nevent, 'nevent1')) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
try { |
||||
$decoded = new Bech32($nevent); |
||||
} catch (\Throwable) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
if ($decoded->type !== 'nevent' || !isset($decoded->data->id)) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
$eventId = strtolower((string) $decoded->data->id); |
||||
if (64 !== \strlen($eventId) || !ctype_xdigit($eventId)) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
$authorHex = $decoded->data->author ?? null; |
||||
if (\is_string($authorHex) && 64 === \strlen($authorHex) && ctype_xdigit($authorHex)) { |
||||
$authorHex = strtolower($authorHex); |
||||
} else { |
||||
$authorHex = null; |
||||
} |
||||
$kind = isset($decoded->data->kind) ? (int) $decoded->data->kind : 1; |
||||
$relays = $decoded->data->relays ?? []; |
||||
$relays = \is_array($relays) ? $relays : []; |
||||
if ($authorHex !== null) { |
||||
$rebuilt = (string) Bech32::nevent( |
||||
id: $eventId, |
||||
relays: $relays, |
||||
author: $authorHex, |
||||
kind: $kind, |
||||
); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$this->nostrKey()->convertPublicKeyToBech32($authorHex), |
||||
$rebuilt, |
||||
null, |
||||
$this->feedJumble($rebuilt), |
||||
); |
||||
} |
||||
|
||||
return new NostrShareMenuContext( |
||||
null, |
||||
$nevent, |
||||
null, |
||||
$this->feedJumble($nevent), |
||||
); |
||||
} |
||||
|
||||
private function forCategory(string $slug): NostrShareMenuContext |
||||
{ |
||||
if ($slug === '') { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
$cat = $this->magazineIndexStore->getCategory($slug); |
||||
if ($cat === null) { |
||||
return $this->siteWithRootMenu(); |
||||
} |
||||
|
||||
return $this->fromNostrEvent($cat) ?? $this->siteWithRootMenu(); |
||||
} |
||||
|
||||
private function fromNostrEvent(Event $e): ?NostrShareMenuContext |
||||
{ |
||||
$id = strtolower($e->getId()); |
||||
if (64 !== \strlen($id) || !ctype_xdigit($id)) { |
||||
return null; |
||||
} |
||||
$pk = strtolower($e->getPubkey()); |
||||
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||
return null; |
||||
} |
||||
$kind = (int) $e->getKind(); |
||||
$d = Nip19Addressable::dTagFromEventEntity($e); |
||||
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk); |
||||
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { |
||||
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$npub, |
||||
null, |
||||
$naddr, |
||||
$this->feedJumble($naddr), |
||||
); |
||||
} |
||||
$nevent = (string) Bech32::nevent( |
||||
id: $id, |
||||
relays: [], |
||||
author: $pk, |
||||
kind: $kind, |
||||
); |
||||
|
||||
return new NostrShareMenuContext( |
||||
$npub, |
||||
$nevent, |
||||
null, |
||||
$this->feedJumble($nevent), |
||||
); |
||||
} |
||||
|
||||
private function siteWithRootMenu(): NostrShareMenuContext |
||||
{ |
||||
$root = $this->magazineIndexStore->getRoot($this->siteNpub, $this->rootDTag); |
||||
if (null === $fromRoot = $root ? $this->fromNostrEvent($root) : null) { |
||||
return new NostrShareMenuContext( |
||||
$this->siteNpub, |
||||
null, |
||||
null, |
||||
$this->profileJumbleUrl($this->siteNpub), |
||||
); |
||||
} |
||||
|
||||
return $fromRoot; |
||||
} |
||||
|
||||
private function profileJumbleUrl(string $npub): string |
||||
{ |
||||
$b = rtrim($this->jumbleProfileUsersBase, '/'); |
||||
|
||||
return $b === '' ? '#' : $b.'/'.$npub; |
||||
} |
||||
|
||||
private function feedJumble(string $naddrOrNeventOrNoteBech32): string |
||||
{ |
||||
$b = rtrim($this->jumbleFeedNotesBase, '/'); |
||||
|
||||
return $b === '' ? $naddrOrNeventOrNoteBech32 : $b.'/'.$naddrOrNeventOrNoteBech32; |
||||
} |
||||
} |
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Service\NostrPathHelper; |
||||
use Twig\Extension\AbstractExtension; |
||||
use Twig\TwigFunction; |
||||
|
||||
final class NostrPathExtension extends AbstractExtension |
||||
{ |
||||
public function __construct( |
||||
private readonly NostrPathHelper $nostrPathHelper, |
||||
) { |
||||
} |
||||
|
||||
public function getFunctions(): array |
||||
{ |
||||
return [ |
||||
new TwigFunction('npub_from_hex', $this->npubFromHex(...)), |
||||
new TwigFunction('article_path', $this->articlePath(...)), |
||||
]; |
||||
} |
||||
|
||||
public function npubFromHex(string $pubkeyHex): string |
||||
{ |
||||
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { |
||||
return ''; |
||||
} |
||||
|
||||
return $this->nostrPathHelper->npubFromPubkeyHex($pubkeyHex); |
||||
} |
||||
|
||||
public function articlePath(Article $article): string |
||||
{ |
||||
return $this->nostrPathHelper->articlePath($article); |
||||
} |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig; |
||||
|
||||
use App\Dto\NostrShareMenuContext; |
||||
use App\Service\NostrShareMenuBuilder; |
||||
use Symfony\Component\HttpFoundation\RequestStack; |
||||
use Twig\Extension\AbstractExtension; |
||||
use Twig\TwigFunction; |
||||
|
||||
final class NostrShareMenuExtension extends AbstractExtension |
||||
{ |
||||
public function __construct( |
||||
private readonly NostrShareMenuBuilder $builder, |
||||
private readonly RequestStack $requestStack, |
||||
) { |
||||
} |
||||
|
||||
public function getFunctions(): array |
||||
{ |
||||
return [ |
||||
new TwigFunction('nostr_share_menu', [$this, 'getOrBuildContext']), |
||||
]; |
||||
} |
||||
|
||||
public function getOrBuildContext(): ?NostrShareMenuContext |
||||
{ |
||||
$request = $this->requestStack->getCurrentRequest(); |
||||
if ($request === null) { |
||||
return null; |
||||
} |
||||
|
||||
return $this->builder->buildForRequest($request); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
{% set share = nostr_share_menu() %} |
||||
{% if share is not null %} |
||||
<details class="nostr-share-menu"> |
||||
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options">⋯</summary> |
||||
<ul class="nostr-share-menu__list" role="menu"> |
||||
{% if share.npub is not null and share.npub is not same as('') %} |
||||
<li class="nostr-share-menu__item" role="none" |
||||
data-controller="copy-text" |
||||
data-copy-text-text-value="{{ share.npub|e('html_attr') }}"> |
||||
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy npub</button> |
||||
</li> |
||||
{% endif %} |
||||
{% if share.naddrBech32 is not null and share.naddrBech32 is not same as('') %} |
||||
<li class="nostr-share-menu__item" role="none" |
||||
data-controller="copy-text" |
||||
data-copy-text-text-value="{{ share.naddrBech32|e('html_attr') }}"> |
||||
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy naddr</button> |
||||
</li> |
||||
{% elseif share.neventBech32 is not null and share.neventBech32 is not same as('') %} |
||||
<li class="nostr-share-menu__item" role="none" |
||||
data-controller="copy-text" |
||||
data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}"> |
||||
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy nevent</button> |
||||
</li> |
||||
{% endif %} |
||||
<li class="nostr-share-menu__item" role="none"> |
||||
<a class="nostr-share-menu__action" role="menuitem" href="{{ share.jumbleHref|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a> |
||||
</li> |
||||
</ul> |
||||
</details> |
||||
{% endif %} |
||||
Loading…
Reference in new issue