Compare commits
10 Commits
3ebea1c7b4
...
f969bc4a57
| Author | SHA1 | Date |
|---|---|---|
|
|
f969bc4a57 | 6 days ago |
|
|
7e6f172731 | 6 days ago |
|
|
ba5332c0d1 | 6 days ago |
|
|
a7f35ff173 | 6 days ago |
|
|
f6703e2f4b | 6 days ago |
|
|
8100706698 | 7 days ago |
|
|
8e12daf8bb | 7 days ago |
|
|
7143a816dd | 7 days ago |
|
|
a155bebd05 | 7 days ago |
|
|
46460e7828 | 7 days ago |
65 changed files with 3643 additions and 629 deletions
@ -0,0 +1,72 @@ |
|||||||
|
# Dev: debug → dev.log only; info and above → dev.log + stderr. |
||||||
|
# Prod: debug–notice → prod.log only; warning+ → prod.log + stderr. Deprecations: stderr (JSON) only. |
||||||
|
# Log rotation: Monolog’s rotating_file rolls daily and keeps the last N files (caps growth; not a strict MB cap). |
||||||
|
|
||||||
|
monolog: |
||||||
|
channels: |
||||||
|
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists |
||||||
|
|
||||||
|
when@dev: |
||||||
|
monolog: |
||||||
|
handlers: |
||||||
|
# Each member gets every record; level filters which are actually written. |
||||||
|
main: |
||||||
|
type: group |
||||||
|
members: [file, docker] |
||||||
|
channels: ["!event"] |
||||||
|
file: |
||||||
|
type: rotating_file |
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log" |
||||||
|
level: debug |
||||||
|
max_files: 14 |
||||||
|
docker: |
||||||
|
type: stream |
||||||
|
path: "php://stderr" |
||||||
|
# Min level info: debug stays out of stderr (file only). |
||||||
|
level: info |
||||||
|
console: |
||||||
|
type: console |
||||||
|
process_psr_3_messages: false |
||||||
|
channels: ["!event", "!doctrine", "!console"] |
||||||
|
|
||||||
|
when@test: |
||||||
|
monolog: |
||||||
|
handlers: |
||||||
|
main: |
||||||
|
type: fingers_crossed |
||||||
|
action_level: error |
||||||
|
handler: nested |
||||||
|
excluded_http_codes: [404, 405] |
||||||
|
channels: ["!event"] |
||||||
|
nested: |
||||||
|
type: stream |
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log" |
||||||
|
level: debug |
||||||
|
|
||||||
|
when@prod: |
||||||
|
monolog: |
||||||
|
handlers: |
||||||
|
# No fingers_crossed: we split explicitly between file (all) and stderr (warning+ only). |
||||||
|
main: |
||||||
|
type: group |
||||||
|
members: [file, stderr] |
||||||
|
channels: ["!deprecation", "!event"] |
||||||
|
file: |
||||||
|
type: rotating_file |
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log" |
||||||
|
level: debug |
||||||
|
max_files: 30 |
||||||
|
stderr: |
||||||
|
type: stream |
||||||
|
path: php://stderr |
||||||
|
level: warning |
||||||
|
formatter: monolog.formatter.json |
||||||
|
console: |
||||||
|
type: console |
||||||
|
process_psr_3_messages: false |
||||||
|
channels: ["!event", "!doctrine"] |
||||||
|
deprecation: |
||||||
|
type: stream |
||||||
|
channels: [deprecation] |
||||||
|
path: php://stderr |
||||||
|
formatter: monolog.formatter.json |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
/** |
||||||
|
* Core Nostr events (30040 indices, kind-0 profiles) live in `event` with a stable {@see Event::getCoreRowKey()}. |
||||||
|
*/ |
||||||
|
final class Version20260424130000 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return 'event.core_row_key + event.storage_role for DB-backed magazine indices and profiles'; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('ALTER TABLE event ADD core_row_key VARCHAR(255) DEFAULT NULL, ADD storage_role VARCHAR(32) DEFAULT NULL'); |
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_3BAE0AA7F6F0AF27 ON event (core_row_key)'); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('DROP INDEX UNIQ_3BAE0AA7F6F0AF27 ON event'); |
||||||
|
$this->addSql('ALTER TABLE event DROP core_row_key, DROP storage_role'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
/** |
||||||
|
* Liveness: no DB or Nostr work. Used by Docker / load balancers; do not use for deep dependency checks. |
||||||
|
*/ |
||||||
|
final class HealthController |
||||||
|
{ |
||||||
|
public const string BODY = "ok\n"; |
||||||
|
|
||||||
|
#[Route('/health', name: 'health', methods: ['GET', 'HEAD'])] |
||||||
|
public function __invoke(Request $request): Response |
||||||
|
{ |
||||||
|
$headers = [ |
||||||
|
'Content-Type' => 'text/plain; charset=UTF-8', |
||||||
|
'Cache-Control' => 'no-store', |
||||||
|
]; |
||||||
|
|
||||||
|
if ($request->isMethod('HEAD')) { |
||||||
|
return new Response('', Response::HTTP_OK, $headers); |
||||||
|
} |
||||||
|
|
||||||
|
return new Response(self::BODY, Response::HTTP_OK, $headers); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Dto; |
||||||
|
|
||||||
|
/** |
||||||
|
* Nostr "⋯" share menu: copy npub; copy naddr and/or nevent (Jumble /feed/notes/… uses the naddr when present, else nevent). |
||||||
|
* For NIP-33 replaceable events, both can be set: naddr is the coordinate, nevent is the specific revision. |
||||||
|
*/ |
||||||
|
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,62 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Nostr; |
||||||
|
|
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
/** |
||||||
|
* Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL. |
||||||
|
*/ |
||||||
|
final class MagazineEventKeys |
||||||
|
{ |
||||||
|
public static function magazineRoot(string $npub, string $rootDTag): string |
||||||
|
{ |
||||||
|
$hex = self::npubToHex($npub); |
||||||
|
if ($hex === '') { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
return 'mr:'.$hex.':'.trim($rootDTag, " \0\x0B\t\n\r"); |
||||||
|
} |
||||||
|
|
||||||
|
public static function magazineCategory(string $categoryDTag): string |
||||||
|
{ |
||||||
|
return 'mc:'.trim($categoryDTag, " \0\x0B\t\n\r"); |
||||||
|
} |
||||||
|
|
||||||
|
public static function profileKind0(string $authorPubkeyHex64): string |
||||||
|
{ |
||||||
|
return 'pr:'.strtolower($authorPubkeyHex64); |
||||||
|
} |
||||||
|
|
||||||
|
public static function relayList10002(string $authorPubkeyHex64): string |
||||||
|
{ |
||||||
|
return 'k10002:'.strtolower($authorPubkeyHex64); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-33 + NIP-A3: kind 10133, pubkey hex, d-tag from the address. |
||||||
|
*/ |
||||||
|
public static function payto10133(string $authorPubkeyHex64, string $dTag): string |
||||||
|
{ |
||||||
|
$d = trim($dTag, " \0\x0B\t\n\r"); |
||||||
|
|
||||||
|
return 'k10133:'.strtolower($authorPubkeyHex64).':'.$d; |
||||||
|
} |
||||||
|
|
||||||
|
private static function npubToHex(string $npub): string |
||||||
|
{ |
||||||
|
if (64 === \strlen($npub) && ctype_xdigit($npub)) { |
||||||
|
return strtolower($npub); |
||||||
|
} |
||||||
|
try { |
||||||
|
$h = (new Key())->convertToHex($npub); |
||||||
|
} catch (\Throwable) { |
||||||
|
$h = ''; |
||||||
|
} |
||||||
|
|
||||||
|
return (\is_string($h) && 64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : ''; |
||||||
|
} |
||||||
|
} |
||||||
@ -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,25 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Repository; |
||||||
|
|
||||||
|
use App\Entity\Event; |
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||||
|
use Doctrine\Persistence\ManagerRegistry; |
||||||
|
|
||||||
|
/** |
||||||
|
* @extends ServiceEntityRepository<Event> |
||||||
|
*/ |
||||||
|
class EventRepository extends ServiceEntityRepository |
||||||
|
{ |
||||||
|
public function __construct(ManagerRegistry $registry) |
||||||
|
{ |
||||||
|
parent::__construct($registry, Event::class); |
||||||
|
} |
||||||
|
|
||||||
|
public function findOneByCoreRowKey(string $key): ?Event |
||||||
|
{ |
||||||
|
return $this->findOneBy(['coreRowKey' => $key]); |
||||||
|
} |
||||||
|
} |
||||||
@ -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,402 @@ |
|||||||
|
<?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'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 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)) |
||||||
|
? (string) Bech32::nevent( |
||||||
|
id: $eventIdHex, |
||||||
|
relays: $relayHints, |
||||||
|
author: $pubkeyHex, |
||||||
|
kind: $kind, |
||||||
|
) |
||||||
|
: null; |
||||||
|
|
||||||
|
return new NostrShareMenuContext( |
||||||
|
$npub, |
||||||
|
$neventForRev, |
||||||
|
$naddr, |
||||||
|
$this->feedJumble($naddr), |
||||||
|
); |
||||||
|
} |
||||||
|
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { |
||||||
|
$rebuilt = (string) Bech32::nevent( |
||||||
|
id: $eventIdHex, |
||||||
|
relays: $relayHints, |
||||||
|
author: $pubkeyHex, |
||||||
|
kind: $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, |
||||||
|
#[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)) |
||||||
|
? (string) Bech32::nevent( |
||||||
|
id: $eid, |
||||||
|
relays: [], |
||||||
|
author: $pk, |
||||||
|
kind: $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 = 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, []); |
||||||
|
$neventForRev = (string) Bech32::nevent( |
||||||
|
id: $id, |
||||||
|
relays: [], |
||||||
|
author: $pk, |
||||||
|
kind: $kind, |
||||||
|
); |
||||||
|
|
||||||
|
return new NostrShareMenuContext( |
||||||
|
$npub, |
||||||
|
$neventForRev, |
||||||
|
$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,65 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use App\Nostr\Nip19Addressable; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
|
||||||
|
/** |
||||||
|
* Footer “View this magazine on Jumble”: Jumble /feed/notes/{naddr} for the site root kind 30040 index. |
||||||
|
*/ |
||||||
|
final class MagazineJumbleExtension extends AbstractExtension |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
#[Autowire('%npub%')] |
||||||
|
private readonly string $siteNpub, |
||||||
|
#[Autowire('%d_tag%')] |
||||||
|
private readonly string $rootMagazineDTag, |
||||||
|
#[Autowire('%jumble_feed_notes_base%')] |
||||||
|
private readonly string $jumbleFeedNotesBase, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getFunctions(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFunction('magazine_on_jumble_url', $this->magazineOnJumbleUrl(...)), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function magazineOnJumbleUrl(): string |
||||||
|
{ |
||||||
|
$key = new Key(); |
||||||
|
try { |
||||||
|
$pubkeyHex = $key->convertToHex($this->siteNpub); |
||||||
|
} catch (\Throwable) { |
||||||
|
return '#'; |
||||||
|
} |
||||||
|
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { |
||||||
|
return '#'; |
||||||
|
} |
||||||
|
$d = \trim($this->rootMagazineDTag); |
||||||
|
if ($d === '') { |
||||||
|
return '#'; |
||||||
|
} |
||||||
|
try { |
||||||
|
$naddr = Nip19Addressable::naddrBech32( |
||||||
|
KindsEnum::PUBLICATION_INDEX->value, |
||||||
|
strtolower($pubkeyHex), |
||||||
|
$d, |
||||||
|
[], |
||||||
|
); |
||||||
|
} catch (\Throwable) { |
||||||
|
return '#'; |
||||||
|
} |
||||||
|
$b = \rtrim($this->jumbleFeedNotesBase, '/'); |
||||||
|
|
||||||
|
return $b === '' ? $naddr : $b.'/'.$naddr; |
||||||
|
} |
||||||
|
} |
||||||
@ -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,63 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Dto\NostrShareMenuContext; |
||||||
|
use App\Entity\Article; |
||||||
|
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']), |
||||||
|
new TwigFunction('nostr_event_share', [$this, 'getEventShareContext']), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function getOrBuildContext(): ?NostrShareMenuContext |
||||||
|
{ |
||||||
|
$request = $this->requestStack->getCurrentRequest(); |
||||||
|
if ($request === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->builder->buildForRequest($request); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Share menu for a specific event: {@see Article} row, wire object (comment / quote / preview), etc. |
||||||
|
* |
||||||
|
* @param mixed $data Article entity or wire-like object (id, pubkey, kind, tags) |
||||||
|
*/ |
||||||
|
public function getEventShareContext(mixed $data): ?NostrShareMenuContext |
||||||
|
{ |
||||||
|
if ($data instanceof Article) { |
||||||
|
return $this->builder->shareContextForArticle($data); |
||||||
|
} |
||||||
|
if ($data === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (\is_array($data)) { |
||||||
|
$json = json_encode($data); |
||||||
|
$data = \is_string($json) ? json_decode($json) : null; |
||||||
|
} |
||||||
|
if (!\is_object($data)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->builder->shareContextFromWireEvent($data, []); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Util; |
||||||
|
|
||||||
|
/** |
||||||
|
* Tag rows from Nostr events may be JSON arrays, associative arrays, or object-shaped. Normalize |
||||||
|
* to a name-first list of string values (see NIP-01 tag structure). |
||||||
|
*/ |
||||||
|
final class NostrEventTags |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @return list<string>|null |
||||||
|
*/ |
||||||
|
public static function rowToStringList(mixed $row): ?array |
||||||
|
{ |
||||||
|
if ($row === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (\is_object($row)) { |
||||||
|
$row = get_object_vars($row); |
||||||
|
} |
||||||
|
if (!\is_array($row) || $row === []) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return array_values( |
||||||
|
array_map( |
||||||
|
static fn (mixed $v): string => (string) $v, |
||||||
|
$row |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public static function tagNameMatches(mixed $row, string $name): bool |
||||||
|
{ |
||||||
|
$seq = self::rowToStringList($row); |
||||||
|
if ($seq === null || $seq === []) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return strtolower($seq[0] ?? '') === strtolower($name); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
{% if share is not defined %} |
||||||
|
{% set share = nostr_share_menu() %} |
||||||
|
{% endif %} |
||||||
|
{% if share is not null %} |
||||||
|
<details class="nostr-share-menu{{ event_menu|default(false) ? ' nostr-share-menu--event' : '' }}"> |
||||||
|
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options"> |
||||||
|
{% if event_menu|default(false) %} |
||||||
|
<span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span> |
||||||
|
{% else %} |
||||||
|
<span class="nostr-share-menu__label">Nostr</span><span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span> |
||||||
|
{% endif %} |
||||||
|
</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> |
||||||
|
{% endif %} |
||||||
|
{% if 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