12 changed files with 711 additions and 0 deletions
@ -0,0 +1,62 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Entity\User; |
||||||
|
use App\Util\CommonMark\Converter; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Psr\Cache\CacheItemPoolInterface; |
||||||
|
use Psr\Cache\InvalidArgumentException; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
class ArticleController extends AbstractController |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
#[Route('/article/d/{slug}', name: 'article-slug')] |
||||||
|
public function article(EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, |
||||||
|
Converter $converter, $slug): Response |
||||||
|
{ |
||||||
|
$article = null; |
||||||
|
// check if an item with same eventId already exists in the db |
||||||
|
$repository = $entityManager->getRepository(Article::class); |
||||||
|
$articles = $repository->findBy(['slug' => $slug]); |
||||||
|
$revisions = count($repository->findBy(['slug' => $slug])); |
||||||
|
|
||||||
|
if ($revisions > 1) { |
||||||
|
// sort articles by created at date |
||||||
|
usort($articles, function ($a, $b) { |
||||||
|
return $b->getCreatedAt() <=> $a->getCreatedAt(); |
||||||
|
}); |
||||||
|
// get the last article |
||||||
|
$article = end($articles); |
||||||
|
} else { |
||||||
|
$article = $articles[0]; |
||||||
|
} |
||||||
|
|
||||||
|
if (!$article) { |
||||||
|
throw $this->createNotFoundException('The article does not exist'); |
||||||
|
} |
||||||
|
|
||||||
|
$cacheKey = 'article_' . $article->getId(); |
||||||
|
$cacheItem = $articlesCache->getItem($cacheKey); |
||||||
|
if (!$cacheItem->isHit()) { |
||||||
|
$cacheItem->set($converter->convertToHtml($article->getContent())); |
||||||
|
$articlesCache->save($cacheItem); |
||||||
|
} |
||||||
|
|
||||||
|
// find user by npub |
||||||
|
$author = $entityManager->getRepository(User::class)->findOneBy(['npub' => $article->getPubkey()]); |
||||||
|
|
||||||
|
return $this->render('Pages/article.html.twig', [ |
||||||
|
'article' => $article, |
||||||
|
'author' => $author, |
||||||
|
'content' => $cacheItem->get() |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,154 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\Bech32; |
||||||
|
|
||||||
|
use Exception; |
||||||
|
|
||||||
|
class Bech32Decoder |
||||||
|
{ |
||||||
|
// Function to decode Bech32 without character length restriction |
||||||
|
/** |
||||||
|
* @throws Exception |
||||||
|
*/ |
||||||
|
public function decodeBech32(string $bech32): array |
||||||
|
{ |
||||||
|
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; |
||||||
|
|
||||||
|
// Find the separator (1) |
||||||
|
$pos = strrpos($bech32, '1'); |
||||||
|
if ($pos === false) { |
||||||
|
throw new Exception('Invalid Bech32 string'); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract human-readable part (HRP) |
||||||
|
$hrp = substr($bech32, 0, $pos); |
||||||
|
|
||||||
|
// Extract data part |
||||||
|
$data_part = substr($bech32, $pos + 1); |
||||||
|
$data = []; |
||||||
|
for ($i = 0; $i < strlen($data_part); $i++) { |
||||||
|
$data[] = strpos($charset, $data_part[$i]); |
||||||
|
if ($data[$i] === false) { |
||||||
|
throw new Exception('Invalid character in Bech32 string'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return [$hrp, $data]; |
||||||
|
} |
||||||
|
|
||||||
|
// Function to convert 5-bit data to 8-bit data |
||||||
|
public function convertBits(array $data, int $fromBits, int $toBits, bool $pad = true): array |
||||||
|
{ |
||||||
|
$acc = 0; |
||||||
|
$bits = 0; |
||||||
|
$ret = []; |
||||||
|
$maxv = (1 << $toBits) - 1; |
||||||
|
$max_acc = (1 << ($fromBits + $toBits - 1)) - 1; |
||||||
|
|
||||||
|
foreach ($data as $value) { |
||||||
|
if ($value < 0 || $value >> $fromBits) { |
||||||
|
throw new Exception('Invalid value in data'); |
||||||
|
} |
||||||
|
$acc = (($acc << $fromBits) | $value) & $max_acc; |
||||||
|
$bits += $fromBits; |
||||||
|
while ($bits >= $toBits) { |
||||||
|
$bits -= $toBits; |
||||||
|
$ret[] = ($acc >> $bits) & $maxv; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($pad) { |
||||||
|
if ($bits > 0) { |
||||||
|
$ret[] = ($acc << ($toBits - $bits)) & $maxv; |
||||||
|
} |
||||||
|
} else if ($bits >= $fromBits || (($acc << ($toBits - $bits)) & $maxv)) { |
||||||
|
throw new Exception('Invalid padding'); |
||||||
|
} |
||||||
|
|
||||||
|
return $ret; |
||||||
|
} |
||||||
|
|
||||||
|
// Public method to decode a Nostr Bech32 string and return hex |
||||||
|
public function decodeNostrBech32ToHex(string $bech32): string |
||||||
|
{ |
||||||
|
list($hrp, $data) = $this->decodeBech32($bech32); |
||||||
|
|
||||||
|
// Convert 5-bit data back to 8-bit data |
||||||
|
$decodedData = $this->convertBits($data, 5, 8, false); |
||||||
|
|
||||||
|
// Return the decoded data as a hex string |
||||||
|
return bin2hex(pack('C*', ...$decodedData)); |
||||||
|
} |
||||||
|
|
||||||
|
// Public method to decode a Nostr Bech32 string and return the binary data |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws Exception |
||||||
|
*/ |
||||||
|
public function decodeNostrBech32ToBinary(string $bech32): array |
||||||
|
{ |
||||||
|
list($hrp, $data) = $this->decodeBech32($bech32); |
||||||
|
|
||||||
|
// Convert 5-bit data to 8-bit data |
||||||
|
$decodedData = $this->convertBits($data, 5, 8); |
||||||
|
|
||||||
|
return [$hrp, $decodedData]; |
||||||
|
} |
||||||
|
|
||||||
|
// Public method to parse the binary data into TLV format |
||||||
|
public function parseTLV(array $binaryData): array |
||||||
|
{ |
||||||
|
$parsedTLVs = []; |
||||||
|
$offset = 0; |
||||||
|
|
||||||
|
while ($offset < count($binaryData)) { |
||||||
|
if ($offset + 1 >= count($binaryData)) { |
||||||
|
throw new Exception("Incomplete TLV data"); |
||||||
|
} |
||||||
|
|
||||||
|
// Read the Type (T) and Length (L) |
||||||
|
$type = $binaryData[$offset]; |
||||||
|
$length = $binaryData[$offset + 1]; |
||||||
|
$offset += 2; |
||||||
|
|
||||||
|
// Ensure we have enough data for the value |
||||||
|
if ($offset + $length > count($binaryData)) { |
||||||
|
break; |
||||||
|
} else { |
||||||
|
// Extract the Value (V) |
||||||
|
$value = array_slice($binaryData, $offset, $length); |
||||||
|
} |
||||||
|
|
||||||
|
$offset += $length; |
||||||
|
|
||||||
|
// Add the TLV to the parsed array |
||||||
|
$parsedTLVs[] = [ |
||||||
|
'type' => $type, |
||||||
|
'length' => $length, |
||||||
|
'value' => $value, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
return $parsedTLVs; |
||||||
|
} |
||||||
|
|
||||||
|
// Decode and parse a Bech32 string |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws Exception |
||||||
|
*/ |
||||||
|
public function decodeAndParseNostrBech32(string $bech32): array |
||||||
|
{ |
||||||
|
// Step 1: Decode Bech32 to binary data |
||||||
|
list($hrp, $binaryData) = $this->decodeNostrBech32ToBinary($bech32); |
||||||
|
|
||||||
|
if ($hrp == 'npub') { |
||||||
|
return [$hrp, $binaryData]; |
||||||
|
} |
||||||
|
|
||||||
|
// Step 2: Parse the binary data into TLV format |
||||||
|
$tlvData = $this->parseTLV($binaryData); |
||||||
|
|
||||||
|
return [$hrp, $tlvData]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark; |
||||||
|
|
||||||
|
use App\Util\Bech32\Bech32Decoder; |
||||||
|
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; |
||||||
|
use League\CommonMark\Environment\Environment; |
||||||
|
use League\CommonMark\Exception\CommonMarkException; |
||||||
|
use League\CommonMark\Extension\Autolink\AutolinkExtension; |
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; |
||||||
|
use League\CommonMark\Extension\Footnote\FootnoteExtension; |
||||||
|
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension; |
||||||
|
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; |
||||||
|
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; |
||||||
|
use League\CommonMark\Extension\Table\TableExtension; |
||||||
|
use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension; |
||||||
|
use League\CommonMark\MarkdownConverter; |
||||||
|
|
||||||
|
class Converter |
||||||
|
{ |
||||||
|
public function __construct(private readonly Bech32Decoder $bech32Decoder) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws CommonMarkException |
||||||
|
*/ |
||||||
|
public function convertToHTML(string $markdown): string |
||||||
|
{ |
||||||
|
// Check if the article has more than three headings |
||||||
|
// Match all headings (from level 1 to 6) |
||||||
|
preg_match_all('/^#+\s.*$/m', $markdown, $matches); |
||||||
|
$headingsCount = count($matches[0]); |
||||||
|
|
||||||
|
|
||||||
|
// Configure the Environment with all the CommonMark parsers/renderers |
||||||
|
$config = [ |
||||||
|
'table_of_contents' => [ |
||||||
|
'min_heading_level' => 1, |
||||||
|
'max_heading_level' => 2, |
||||||
|
], |
||||||
|
'heading_permalink' => [ |
||||||
|
'symbol' => '§', |
||||||
|
], |
||||||
|
'autolink' => [ |
||||||
|
'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp'] |
||||||
|
'default_protocol' => 'https', // defaults to 'http' |
||||||
|
], |
||||||
|
]; |
||||||
|
$environment = new Environment($config); |
||||||
|
// Add the extensions |
||||||
|
$environment->addExtension(new CommonMarkCoreExtension()); |
||||||
|
$environment->addExtension(new FootnoteExtension()); |
||||||
|
$environment->addExtension(new TableExtension()); |
||||||
|
$environment->addExtension(new StrikethroughExtension()); |
||||||
|
// create a custom extension, that handles nostr mentions |
||||||
|
$environment->addExtension(new NostrSchemeExtension($this->bech32Decoder)); |
||||||
|
$environment->addExtension(new SmartPunctExtension()); |
||||||
|
$environment->addExtension(new AutolinkExtension()); |
||||||
|
if ($headingsCount > 3) { |
||||||
|
$environment->addExtension(new HeadingPermalinkExtension()); |
||||||
|
$environment->addExtension(new TableOfContentsExtension()); |
||||||
|
} |
||||||
|
|
||||||
|
// Instantiate the converter engine and start converting some Markdown! |
||||||
|
$converter = new MarkdownConverter($environment); |
||||||
|
$content = html_entity_decode($markdown); |
||||||
|
|
||||||
|
dump($content); |
||||||
|
|
||||||
|
return $converter->convert($content); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Node\Node; |
||||||
|
use League\CommonMark\Renderer\ChildNodeRendererInterface; |
||||||
|
use League\CommonMark\Renderer\NodeRendererInterface; |
||||||
|
use League\CommonMark\Util\HtmlElement; |
||||||
|
|
||||||
|
class NostrEventRenderer implements NodeRendererInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function render(Node $node, ChildNodeRendererInterface $childRenderer) |
||||||
|
{ |
||||||
|
if (!($node instanceof NostrSchemeData)) { |
||||||
|
throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node)); |
||||||
|
} |
||||||
|
|
||||||
|
if ($node->getType() === 'nevent') { |
||||||
|
// Construct the local link URL from the special part |
||||||
|
$url = '/e/' . $node->getSpecial(); |
||||||
|
} else if ($node->getType() === 'naddr') { |
||||||
|
dump($node); |
||||||
|
// Construct the local link URL from the special part |
||||||
|
$url = '/' . $node->getSpecial(); |
||||||
|
} |
||||||
|
|
||||||
|
if (isset($url)) { |
||||||
|
// Create the anchor element |
||||||
|
return new HtmlElement('a', ['href' => $url], '@' . $node->getSpecial()); |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Node\Inline\AbstractInline; |
||||||
|
|
||||||
|
class NostrMentionLink extends AbstractInline |
||||||
|
{ |
||||||
|
private ?string $label; |
||||||
|
private string $npub; |
||||||
|
|
||||||
|
public function __construct(?string $label, string $npubPart) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
|
||||||
|
$this->label = $label; |
||||||
|
$this->npub = $npubPart; |
||||||
|
} |
||||||
|
|
||||||
|
public function getLabel(): ?string |
||||||
|
{ |
||||||
|
return $this->label; |
||||||
|
} |
||||||
|
|
||||||
|
public function getNpub(): string |
||||||
|
{ |
||||||
|
return $this->npub; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Parser\Inline\InlineParserInterface; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserMatch; |
||||||
|
use League\CommonMark\Parser\InlineParserContext; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
/** |
||||||
|
* Class NostrMentionParser |
||||||
|
* Looks for links that look like Markdown links in the format `[label](url)`, |
||||||
|
* but have npub1XXXX instead of a URL |
||||||
|
* @package App\Util\CommonMark |
||||||
|
*/ |
||||||
|
class NostrMentionParser implements InlineParserInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function getMatchDefinition(): InlineParserMatch |
||||||
|
{ |
||||||
|
// Define a match for a markdown link-like structure with "npub" links |
||||||
|
return InlineParserMatch::regex('\[([^\]]+)\]\(npub1[0-9a-zA-Z]+\)'); |
||||||
|
} |
||||||
|
|
||||||
|
public function parse(InlineParserContext $inlineContext): bool |
||||||
|
{ |
||||||
|
$cursor = $inlineContext->getCursor(); |
||||||
|
// Get the match and extract relevant parts |
||||||
|
$matches = $inlineContext->getMatches(); |
||||||
|
|
||||||
|
// The entire match is like "[label](npubXXXX)", now we need to extract "label" and "npubXXXX" |
||||||
|
$fullMatch = $matches[0]; // Full matched string like "[label](npubXXXX)" |
||||||
|
$label = $matches[1]; // This is the text between the square brackets: "label" |
||||||
|
|
||||||
|
// Extract "npub" part from fullMatch |
||||||
|
$npubLink = substr($fullMatch, strpos($fullMatch, 'npub1'), -1); // e.g., "npubXXXX" |
||||||
|
$npubPart = substr($npubLink, 5); // Extract the part after "npub1", i.e., "XXXX" |
||||||
|
|
||||||
|
$key = new Key(); |
||||||
|
$hex = $key->convertToHex($npubLink); |
||||||
|
|
||||||
|
// Create a new inline node for the custom link |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrMentionLink($label, $hex)); |
||||||
|
|
||||||
|
// Advance the cursor to consume the matched part (important!) |
||||||
|
$cursor->advanceBy(strlen($fullMatch)); |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Node\Node; |
||||||
|
use League\CommonMark\Renderer\ChildNodeRendererInterface; |
||||||
|
use League\CommonMark\Renderer\NodeRendererInterface; |
||||||
|
use League\CommonMark\Util\HtmlElement; |
||||||
|
|
||||||
|
class NostrMentionRenderer implements NodeRendererInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement |
||||||
|
{ |
||||||
|
if (!($node instanceof NostrMentionLink)) { |
||||||
|
throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node)); |
||||||
|
} |
||||||
|
|
||||||
|
$label = $node->getLabel() ?? $this->labelFromKey($node->getNpub()); |
||||||
|
|
||||||
|
// Construct the local link URL from the npub part |
||||||
|
$url = '/p/' . $node->getNpub(); |
||||||
|
|
||||||
|
// Create the anchor element |
||||||
|
return new HtmlElement('a', ['href' => $url], '@' . $label); |
||||||
|
} |
||||||
|
|
||||||
|
private function labelFromKey($npub): string |
||||||
|
{ |
||||||
|
$start = substr($npub, 0, 5); // First 5 characters |
||||||
|
$end = substr($npub, -5); // Last 5 characters |
||||||
|
return $start . '...' . $end; // Concatenate with ellipsis |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Parser\Inline\InlineParserInterface; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserMatch; |
||||||
|
use League\CommonMark\Parser\InlineParserContext; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
/** |
||||||
|
* Class NostrRawNpubParser |
||||||
|
* Looks for raw nostr mentions formatted as npub1XXXX |
||||||
|
*/ |
||||||
|
class NostrRawNpubParser implements InlineParserInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public function getMatchDefinition(): InlineParserMatch |
||||||
|
{ |
||||||
|
return InlineParserMatch::regex('npub1[0-9a-zA-Z]+'); |
||||||
|
} |
||||||
|
|
||||||
|
public function parse(InlineParserContext $inlineContext): bool |
||||||
|
{ |
||||||
|
$cursor = $inlineContext->getCursor(); |
||||||
|
// Get the match and extract relevant parts |
||||||
|
$fullMatch = $inlineContext->getFullMatch(); |
||||||
|
$key = new Key(); |
||||||
|
$hex = $key->convertToHex($fullMatch); |
||||||
|
|
||||||
|
// Create a new inline node for the custom link |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $hex)); |
||||||
|
|
||||||
|
// Advance the cursor to consume the matched part (important!) |
||||||
|
$cursor->advanceBy(strlen($fullMatch)); |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Node\Inline\AbstractInline; |
||||||
|
|
||||||
|
/** |
||||||
|
* Class NostrSchemeData |
||||||
|
* NIP-19 bech32-encoded entities |
||||||
|
* |
||||||
|
* @package App\Util\CommonMark |
||||||
|
*/ |
||||||
|
class NostrSchemeData extends AbstractInline |
||||||
|
{ |
||||||
|
private $type; |
||||||
|
private $special; |
||||||
|
private $relays; |
||||||
|
private $author; |
||||||
|
private $kind; |
||||||
|
|
||||||
|
public function __construct($type, $special, $relays, $author, $kind) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
|
||||||
|
$this->type = $type; |
||||||
|
$this->special = $special; |
||||||
|
$this->relays = $relays; |
||||||
|
$this->author = $author; |
||||||
|
$this->kind = $kind; |
||||||
|
} |
||||||
|
|
||||||
|
public function getType() |
||||||
|
{ |
||||||
|
return $this->type; |
||||||
|
} |
||||||
|
|
||||||
|
public function getSpecial() |
||||||
|
{ |
||||||
|
return $this->special; |
||||||
|
} |
||||||
|
|
||||||
|
public function getRelays() |
||||||
|
{ |
||||||
|
return $this->relays; |
||||||
|
} |
||||||
|
|
||||||
|
public function getAuthor() |
||||||
|
{ |
||||||
|
return $this->author; |
||||||
|
} |
||||||
|
|
||||||
|
public function getKind() |
||||||
|
{ |
||||||
|
return $this->kind; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use App\Util\Bech32\Bech32Decoder; |
||||||
|
use League\CommonMark\Environment\EnvironmentBuilderInterface; |
||||||
|
use League\CommonMark\Extension\ExtensionInterface; |
||||||
|
|
||||||
|
class NostrSchemeExtension implements ExtensionInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function __construct(private readonly Bech32Decoder $bech32Decoder) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public function register(EnvironmentBuilderInterface $environment): void |
||||||
|
{ |
||||||
|
$environment |
||||||
|
->addInlineParser(new NostrMentionParser(), 200) |
||||||
|
->addInlineParser(new NostrSchemeParser($this->bech32Decoder), 199) |
||||||
|
->addInlineParser(new NostrRawNpubParser(), 198) |
||||||
|
|
||||||
|
->addRenderer(NostrSchemeData::class, new NostrEventRenderer(), 2) |
||||||
|
->addRenderer(NostrMentionLink::class, new NostrMentionRenderer(), 1) |
||||||
|
; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,102 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\NostrSchemeExtension; |
||||||
|
|
||||||
|
use App\Util\Bech32\Bech32Decoder; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserInterface; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserMatch; |
||||||
|
use League\CommonMark\Parser\InlineParserContext; |
||||||
|
use function BitWasp\Bech32\convertBits; |
||||||
|
use function BitWasp\Bech32\decode; |
||||||
|
|
||||||
|
|
||||||
|
class NostrSchemeParser implements InlineParserInterface |
||||||
|
{ |
||||||
|
|
||||||
|
public function __construct(private Bech32Decoder $bech32Decoder) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public function getMatchDefinition(): InlineParserMatch |
||||||
|
{ |
||||||
|
return InlineParserMatch::regex('nostr:[0-9a-zA-Z]+'); |
||||||
|
} |
||||||
|
|
||||||
|
public function parse(InlineParserContext $inlineContext): bool |
||||||
|
{ |
||||||
|
$cursor = $inlineContext->getCursor(); |
||||||
|
// Get the match and extract relevant parts |
||||||
|
$fullMatch = $inlineContext->getFullMatch(); |
||||||
|
// The match is a Bech32 encoded string |
||||||
|
// decode it to get the parts |
||||||
|
$bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX" |
||||||
|
dump($bechEncoded); |
||||||
|
|
||||||
|
try { |
||||||
|
list($hrp, $tlv) = $this->bech32Decoder->decodeAndParseNostrBech32($bechEncoded); |
||||||
|
dump($hrp); |
||||||
|
dump($tlv); |
||||||
|
switch ($hrp) { |
||||||
|
case 'npub': |
||||||
|
$str = ''; |
||||||
|
list($hrp, $data) = decode($bechEncoded); |
||||||
|
$bytes = convertBits($data, count($data), 5, 8, false); |
||||||
|
foreach ($bytes as $item) { |
||||||
|
$str .= str_pad(dechex($item), 2, '0', STR_PAD_LEFT); |
||||||
|
} |
||||||
|
$npubPart = $str; |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $npubPart)); |
||||||
|
break; |
||||||
|
case 'nprofile': |
||||||
|
$type = 0; // npub |
||||||
|
foreach ($tlv as $item) { |
||||||
|
if ($item['type'] === $type) { |
||||||
|
// from array of integers to string |
||||||
|
$str = ''; |
||||||
|
foreach ($item['value'] as $byte) { |
||||||
|
$str .= str_pad(dechex($byte), 2, '0', STR_PAD_LEFT); |
||||||
|
} |
||||||
|
$npubPart = $str; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
if (isset($npubPart)) { |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $npubPart)); |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'nevent': |
||||||
|
foreach ($tlv as $item) { |
||||||
|
// event id |
||||||
|
if ($item['type'] === 0) { |
||||||
|
$eventId = implode('', array_map(fn($byte) => sprintf('%02x', $byte), $item['value'])); |
||||||
|
break; |
||||||
|
} |
||||||
|
// relays |
||||||
|
if ($item['type'] === 1) { |
||||||
|
$relays[] = implode('', array_map('chr', $item['value'])); |
||||||
|
} |
||||||
|
} |
||||||
|
dump($relays ?? null); |
||||||
|
// TODO also potentially contains relays, author, and kind |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $eventId, $relays ?? null, null, null)); |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
$inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, null, null, null)); |
||||||
|
break; |
||||||
|
case 'nrelay': |
||||||
|
// deprecated |
||||||
|
default: |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
} catch (\Exception $e) { |
||||||
|
dump($e->getMessage()); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Advance the cursor to consume the matched part (important!) |
||||||
|
$cursor->advanceBy(strlen($fullMatch)); |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<div class="card"> |
||||||
|
<div class="card-header"> |
||||||
|
<h1 class="card-title">{{ article.title }}</h1> |
||||||
|
</div> |
||||||
|
{% if author %} |
||||||
|
<div class="byline"> |
||||||
|
<span> |
||||||
|
{{ 'text.byline'|trans }} <a href="{{ path('author', {'npub': author.npub}) }}"> |
||||||
|
<twig:NameOrNpub displayName="{{ author.displayName }}" name="{{ author.name }}" npub="{{ author.npub }}" /> |
||||||
|
</a> |
||||||
|
</span> |
||||||
|
<span> |
||||||
|
<small><twig:ux:icon name="heroicons:pencil" class="icon" /> {{ article.createdAt|date('F j, Y') }}</small><br> |
||||||
|
{% if article.publishedAt is not null %}<small>{{ article.publishedAt|date('F j, Y') }}</small>{% endif %} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<div class="lede"> |
||||||
|
{{ article.summary }} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if article.image %} |
||||||
|
<div class="article__image"> |
||||||
|
<img src="{{ article.image }}" alt="{{ article.title }}"> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="article-main"> |
||||||
|
{{ content|raw }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<hr class="divider" /> |
||||||
|
<div class="tags"> |
||||||
|
{% for tag in article.topics %} |
||||||
|
<span class="tag">{{ tag }}</span> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue