You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

373 lines
13 KiB

<?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\Nostr\Nip19Codec;
use App\Repository\ArticleRepository;
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';
/**
* NIP-19 + Jumble href for a wire event (replies, quotes, previews, /e/ page).
*/
public function shareContextFromWireEvent(object $event, array $relayHints = []): ?NostrShareMenuContext
{
$pubkeyHex = strtolower((string) ($event->pubkey ?? ''));
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return null;
}
$key = new Key();
$npub = $key->convertPublicKeyToBech32($pubkeyHex);
$kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event);
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex))
? $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind)
: null;
return new NostrShareMenuContext(
$npub,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);
}
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = $this->nip19->encodeNevent($eventIdHex, $relayHints, $pubkeyHex, $kind);
return new NostrShareMenuContext(
$npub,
$rebuilt,
null,
$this->feedJumble($rebuilt),
);
}
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
public function shareContextForArticle(Article $article): NostrShareMenuContext
{
return $this->fromArticle($article);
}
public function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void
{
$ctx = $this->shareContextFromWireEvent($event, $relayHints);
if (null === $ctx || null === $ctx->npub) {
return;
}
$request->attributes->set(self::ATTR_NPUB, $ctx->npub);
if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') {
$request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32);
}
if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') {
$request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32);
}
}
/**
* @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,
private readonly Nip19Codec $nip19,
#[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();
}
/**
* Context for the header Nostr menu. Always returns a context on real HTTP requests (never null).
* Templates that do not include the header never call this; no need to suppress on XHR / fragments.
*/
public function buildForRequest(Request $request): NostrShareMenuContext
{
$route = (string) $request->attributes->get('_route', '');
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, []);
$eid = strtolower((string) ($article->getEventId() ?? ''));
$nevent = (64 === \strlen($eid) && ctype_xdigit($eid))
? $this->nip19->encodeNevent($eid, [], $pk, $kind)
: null;
return new NostrShareMenuContext(
$npub,
$nevent,
$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) || $request->attributes->has(self::ATTR_NEVENT_BECH32))) {
$np = (string) $request->attributes->get(self::ATTR_NPUB);
$naddrRaw = $request->attributes->get(self::ATTR_NADDR_BECH32);
$naddr = \is_string($naddrRaw) && $naddrRaw !== '' ? $naddrRaw : null;
$neventRaw = $request->attributes->get(self::ATTR_NEVENT_BECH32);
$nb = \is_string($neventRaw) && $neventRaw !== '' ? $neventRaw : null;
if (null !== $naddr || null !== $nb) {
$jumble = $this->feedJumble($naddr ?? $nb);
return new NostrShareMenuContext(
$np,
$nb,
$naddr,
$jumble,
);
}
}
$nevent = $neventFromRoute;
if ($nevent === '' || !str_starts_with($nevent, 'nevent1')) {
return $this->siteWithRootMenu();
}
try {
$decoded = $this->nip19->decode($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 = $this->nip19->encodeNevent($eventId, $relays, $authorHex, $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, []);
$neventForRev = $this->nip19->encodeNevent($id, [], $pk, $kind);
return new NostrShareMenuContext(
$npub,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);
}
$nevent = $this->nip19->encodeNevent($id, [], $pk, $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;
}
}