diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index 7d50e74..d78b6dd 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -9,19 +9,30 @@ export default class extends Controller { connect() { console.log('Nostr publish controller connected'); - this.checkNostrSupport(); - } + // Helpful debug to verify values are wired from Twig + try { + console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); + console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); + } catch (_) {} - checkNostrSupport() { - if (!window.nostr) { - this.showError('Nostr extension not found. Please install a Nostr browser extension like nos2x or Alby.'); - this.publishButtonTarget.disabled = true; + // Provide a sensible fallback if not passed via values + if (!this.hasPublishUrlValue || !this.publishUrlValue) { + this.publishUrlValue = '/api/article/publish'; } } async publish(event) { event.preventDefault(); + if (!this.publishUrlValue) { + this.showError('Publish URL is not configured'); + return; + } + if (!this.csrfTokenValue) { + this.showError('Missing CSRF token'); + return; + } + if (!window.nostr) { this.showError('Nostr extension not found'); return; @@ -218,6 +229,9 @@ export default class extends Controller { } generateSlug(title) { + // add a random seed at the end of the title to avoid collisions + const randomSeed = Math.random().toString(36).substring(2, 8); + title = `${title} ${randomSeed}`; return title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') // Remove special characters diff --git a/assets/styles/card.css b/assets/styles/card.css index f328ad1..d3b79f9 100644 --- a/assets/styles/card.css +++ b/assets/styles/card.css @@ -54,6 +54,10 @@ h2.card-title { padding: 10px; } +.card.comment.zap-comment { + border-left: 4px solid var(--color-primary); +} + .card.comment .metadata { display: flex; align-items: center; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index b2f6cd8..ac2825f 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -15,12 +15,14 @@ use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Data\NAddr; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; +use swentel\nostr\Event\Event; use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Workflow\WorkflowInterface; @@ -30,7 +32,7 @@ class ArticleController extends AbstractController /** * @throws \Exception */ - #[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-z]{59})$'])] + #[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-zA-Z]+)$'])] public function naddr(NostrClient $nostrClient, $naddr) { $decoded = new Bech32($naddr); @@ -98,7 +100,7 @@ class ArticleController extends AbstractController $article = $articles[0]; } - $cacheKey = 'article_' . $article->getId(); + $cacheKey = 'article_' . $article->getEventId(); $cacheItem = $articlesCache->getItem($cacheKey); if (!$cacheItem->isHit()) { $cacheItem->set($converter->convertToHtml($article->getContent())); @@ -118,60 +120,6 @@ class ArticleController extends AbstractController ]); } - /** - * Fetch complete event to show as preview - * POST data contains an object with request params - */ - #[Route('/preview/', name: 'article-preview-event', methods: ['POST'])] - public function articlePreviewEvent( - Request $request, - NostrClient $nostrClient, - RedisCacheService $redisCacheService, - CacheItemPoolInterface $articlesCache - ): Response { - $data = $request->getContent(); - // descriptor is an object with properties type, identifier and data - // if type === 'nevent', identifier is the event id - // if type === 'naddr', identifier is the naddr - // if type === 'nprofile', identifier is the npub - $descriptor = json_decode($data); - $previewData = []; - - // if nprofile, get from redis cache - if ($descriptor->type === 'nprofile') { - $hint = json_decode($descriptor->decoded); - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($hint->pubkey); - $metadata = $redisCacheService->getMetadata($npub); - $metadata->npub = $npub; - $metadata->pubkey = $hint->pubkey; - $metadata->type = 'nprofile'; - // Render the NostrPreviewContent component with the preview data - $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ - 'preview' => $metadata - ]); - } else { - // For nevent or naddr, fetch the event data - try { - $previewData = $nostrClient->getEventFromDescriptor($descriptor); - $previewData->type = $descriptor->type; // Add type to the preview data - // Render the NostrPreviewContent component with the preview data - $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ - 'preview' => $previewData - ]); - } catch (\Exception $e) { - $html = 'Error fetching preview: ' . htmlspecialchars($e->getMessage()) . ''; - } - } - - - return new Response( - $html, - Response::HTTP_OK, - ['Content-Type' => 'text/html'] - ); - } - /** * Create new article * @throws InvalidArgumentException @@ -234,33 +182,6 @@ class ArticleController extends AbstractController ]); } - /** - * Preview article - * @throws InvalidArgumentException - * @throws CommonMarkException - * @throws \Exception - */ - #[Route('/article-preview/{d}', name: 'article-preview')] - public function preview($d, Converter $converter, - CacheItemPoolInterface $articlesCache): Response - { - $user = $this->getUser(); - $key = new Key(); - $currentPubkey = $key->convertToHex($user->getUserIdentifier()); - - $cacheKey = 'article_' . $currentPubkey . '_' . $d; - $cacheItem = $articlesCache->getItem($cacheKey); - $article = $cacheItem->get(); - - $content = $converter->convertToHtml($article->getContent()); - - return $this->render('pages/article.html.twig', [ - 'article' => $article, - 'content' => $content, - 'author' => $user->getMetadata(), - ]); - } - /** * API endpoint to receive and process signed Nostr events * @throws \Exception @@ -276,7 +197,7 @@ class ArticleController extends AbstractController try { // Verify CSRF token $csrfToken = $request->headers->get('X-CSRF-TOKEN'); - if (!$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('nostr_publish', $csrfToken))) { + if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) { return new JsonResponse(['error' => 'Invalid CSRF token'], 403); } @@ -286,15 +207,20 @@ class ArticleController extends AbstractController return new JsonResponse(['error' => 'Invalid request data'], 400); } + /* @var array $signedEvent */ $signedEvent = $data['event']; - $formData = $data['formData'] ?? []; - - // Validate Nostr event structure - $this->validateNostrEvent($signedEvent); - - // Verify the event signature - if (!$this->verifyNostrSignature($signedEvent)) { - return new JsonResponse(['error' => 'Invalid event signature'], 400); + // Convert the signed event array to a proper Event object + $eventObj = new Event(); + $eventObj->setId($signedEvent['id']); + $eventObj->setPublicKey($signedEvent['pubkey']); + $eventObj->setCreatedAt($signedEvent['created_at']); + $eventObj->setKind($signedEvent['kind']); + $eventObj->setTags($signedEvent['tags']); + $eventObj->setContent($signedEvent['content']); + $eventObj->setSignature($signedEvent['sig']); + + if (!$eventObj->verify()) { + return new JsonResponse(['error' => 'Event signature verification failed'], 400); } // Check if user is authenticated and matches the event pubkey @@ -303,6 +229,8 @@ class ArticleController extends AbstractController return new JsonResponse(['error' => 'User not authenticated'], 401); } + $formData = $data['formData'] ?? []; + $key = new Key(); $currentPubkey = $key->convertToHex($user->getUserIdentifier()); @@ -313,25 +241,12 @@ class ArticleController extends AbstractController // Extract article data from the signed event $articleData = $this->extractArticleDataFromEvent($signedEvent, $formData); - // Check if article with same slug already exists for this author - $repository = $entityManager->getRepository(Article::class); - $existingArticle = $repository->findOneBy([ - 'slug' => $articleData['slug'], - 'pubkey' => $currentPubkey - ]); - - if ($existingArticle) { - // Update existing article (NIP-33 replaceable event) - $article = $existingArticle; - } else { - // Create new article - $article = new Article(); - $article->setPubkey($currentPubkey); - $article->setKind(KindsEnum::LONGFORM); - } - // Update article properties - $article->setEventId($this->generateEventId($signedEvent)); + // Create new article + $article = new Article(); + $article->setPubkey($currentPubkey); + $article->setKind(KindsEnum::LONGFORM); + $article->setEventId($signedEvent['id']); $article->setSlug($articleData['slug']); $article->setTitle($articleData['title']); $article->setSummary($articleData['summary']); @@ -343,44 +258,25 @@ class ArticleController extends AbstractController $article->setCreatedAt(new \DateTimeImmutable('@' . $signedEvent['created_at'])); $article->setPublishedAt(new \DateTimeImmutable()); - // Check workflow permissions - if ($articlePublishingWorkflow->can($article, 'publish')) { - $articlePublishingWorkflow->apply($article, 'publish'); - } - // Save to database $entityManager->persist($article); $entityManager->flush(); // Optionally publish to Nostr relays try { - // Convert the signed event array to a proper Event object - $eventObj = new \swentel\nostr\Event\Event(); - $eventObj->setId($signedEvent['id']); - $eventObj->setPublicKey($signedEvent['pubkey']); - $eventObj->setCreatedAt($signedEvent['created_at']); - $eventObj->setKind($signedEvent['kind']); - $eventObj->setTags($signedEvent['tags']); - $eventObj->setContent($signedEvent['content']); - $eventObj->setSignature($signedEvent['sig']); - // Get user's relays or use default ones $relays = []; if ($user && method_exists($user, 'getRelays') && $user->getRelays()) { foreach ($user->getRelays() as $relayArr) { if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') { - $relays[] = $relayArr[1]; + // $relays[] = $relayArr[1]; } } } // Fallback to default relays if no user relays found if (empty($relays)) { - $relays = [ - 'wss://relay.damus.io', - 'wss://relay.primal.net', - 'wss://nos.lol' - ]; + throw new \Exception('No write relays configured for user.'); } $nostrClient->publishEvent($eventObj, $relays); @@ -502,11 +398,4 @@ class ArticleController extends AbstractController return $data; } - private function generateEventId(array $event): string - { - return $event['id']; - } - - // ...existing code... - } diff --git a/src/Enum/KindsEnum.php b/src/Enum/KindsEnum.php index 714f1fd..81fe4b4 100644 --- a/src/Enum/KindsEnum.php +++ b/src/Enum/KindsEnum.php @@ -16,7 +16,7 @@ enum KindsEnum: int case LONGFORM = 30023; // NIP-23 case LONGFORM_DRAFT = 30024; // NIP-23 case PUBLICATION_INDEX = 30040; - case ZAP = 9735; // NIP-57, Zaps + case ZAP_RECEIPT = 9735; // NIP-57, Zaps case HIGHLIGHTS = 9802; case RELAY_LIST = 10002; // NIP-65, Relay list metadata case APP_DATA = 30078; // NIP-78, Arbitrary custom app data diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index 3cc45d2..0091d3e 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -9,8 +9,6 @@ use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; use App\Form\DataTransformer\HtmlToMdTransformer; use App\Form\Type\QuillType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; @@ -42,22 +40,7 @@ class EditorType extends AbstractType 'required' => false, 'sanitize_html' => true, 'help' => 'Separate tags with commas, skip #', - 'attr' => ['placeholder' => 'Enter tags', 'class' => 'form-control']]) - ->add( - $builder->create('actions', FormType::class, - ['row_attr' => ['class' => 'actions'], 'label' => false, 'mapped' => false]) - ->add('submit', SubmitType::class, [ - 'label' => 'Submit', - 'attr' => ['class' => 'btn btn-primary']]) - ->add('draft', SubmitType::class, [ - 'label' => 'Save as draft', - 'attr' => ['class' => 'btn btn-secondary']]) - ->add('preview', SubmitType::class, [ - 'label' => 'Preview', - 'attr' => ['class' => 'btn btn-secondary']]) - ); - - + 'attr' => ['placeholder' => 'Enter tags', 'class' => 'form-control']]); // Apply the custom transformer $builder->get('topics') diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 4f895aa..8a49521 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -432,7 +432,7 @@ class NostrClient // Create request using the helper method $request = $this->createNostrRequest( - kinds: [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value, KindsEnum::ZAP->value], + kinds: [KindsEnum::COMMENTS->value, KindsEnum::ZAP_RECEIPT->value], filters: ['tag' => ['#a', [$coordinate]]], relaySet: $relaySet ); @@ -470,7 +470,7 @@ class NostrClient // Create request using the helper method // Zaps are kind 9735 $request = $this->createNostrRequest( - kinds: [KindsEnum::ZAP], + kinds: [KindsEnum::ZAP_RECEIPT], filters: ['tag' => ['#a', [$coordinate]]], relaySet: $relaySet ); diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php index cdb3db0..fe10456 100644 --- a/src/Twig/Components/Organisms/Comments.php +++ b/src/Twig/Components/Organisms/Comments.php @@ -12,6 +12,8 @@ final class Comments public array $list = []; public array $commentLinks = []; public array $processedContent = []; + public array $zapAmounts = []; + public array $zappers = []; public function __construct( private readonly NostrClient $nostrClient, @@ -27,9 +29,12 @@ final class Comments { // Fetch comments $this->list = $this->nostrClient->getComments($current); - + // sort list by created_at descending + usort($this->list, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0)); // Parse Nostr links in comments but don't fetch previews $this->parseNostrLinks(); + // Parse Zaps to get amounts and zappers from receipts + $this->parseZaps(); } /** @@ -55,4 +60,132 @@ final class Comments } } } + + /** + * Parse Zaps to get amounts + */ + public function parseZaps(): void + { + foreach ($this->list as $comment) { + // check if kind is 9735 to get zaps + if ($comment->kind !== 9735) { + continue; + } + + $tags = $comment->tags ?? []; + if (empty($tags) || !is_array($tags)) { + continue; + } + + // 1) Find and decode description JSON + $descriptionJson = $this->findTagValue($tags, 'description'); + + if ($descriptionJson === null) { + continue; // can't validate sender without description + } + $description = json_decode($descriptionJson); + + // 3) Get amount: prefer explicit 'amount' tag (msat), fallback to BOLT11 invoice parsing + $amountSats = null; + + $amountMsatStr = $this->findTagValue($description->tags, 'amount'); + if ($amountMsatStr !== null && is_numeric($amountMsatStr)) { + // amount in millisats per NIP-57 + $msats = (int) $amountMsatStr; + if ($msats > 0) { + $amountSats = intdiv($msats, 1000); + } + } + + if (empty($amountSats)) { + $bolt11 = $this->findTagValue($tags, 'bolt11'); + if (!empty($bolt11)) { + $amountSats = $this->parseBolt11ToSats($bolt11); + } + } + + $this->zappers[$comment->id] = $this->findTagValue($tags, 'P'); + if ($amountSats !== null && $amountSats > 0) { + $this->zapAmounts[$comment->id] = $amountSats; + } + } + } + + // --- Helpers --- + + /** + * Find first tag value by key. + * @param array $tags + * @param string $key + * @return string|null + */ + private function findTagValue(array $tags, string $key): ?string + { + foreach ($tags as $tag) { + if (!is_array($tag) || count($tag) < 2) { + continue; + } + if (($tag[0] ?? null) === $key) { + return (string) $tag[1]; + } + } + return null; + } + + /** + * Minimal BOLT11 amount parser to sats from invoice prefix (lnbc...amount[munp]). + * Returns null if amount is not embedded. + */ + private function parseBolt11ToSats(string $invoice): ?int + { + // Normalize + $inv = strtolower(trim($invoice)); + // Find the amount section after the hrp prefix (lnbc or lntb etc.) + // Spec: ln + currency + amount + multiplier. We only need amount+multiplier. + if (!str_starts_with($inv, 'ln')) { + return null; + } + // Strip prefix up to first digit + $i = 0; + while ($i < strlen($inv) && !ctype_digit($inv[$i])) { + $i++; + } + if ($i >= strlen($inv)) { + return null; // no amount encoded + } + // Read numeric part + $j = $i; + while ($j < strlen($inv) && ctype_digit($inv[$j])) { + $j++; + } + $numStr = substr($inv, $i, $j - $i); + if ($numStr === '') { + return null; + } + $amount = (int) $numStr; + // Multiplier char (optional). If none, amount is in BTC (rare for zaps) + $mult = $inv[$j] ?? ''; + $sats = null; + // 1 BTC = 100_000_000 sats + switch ($mult) { + case 'm': // milli-btc + $sats = (int) round($amount * 100_000); // 0.001 BTC = 100_000 sats + break; + case 'u': // micro-btc + $sats = (int) round($amount * 1000); // 0.000001 BTC = 1000 sats + break; + case 'n': // nano-btc + $sats = (int) round($amount * 0.001); // 0.000000001 BTC = 0.001 sats + break; + case 'p': // pico-btc + $sats = (int) round($amount * 0.000001); // 1e-12 BTC = 1e-4 sats + break; + default: + // No multiplier => amount in BTC + $sats = (int) round($amount * 100_000_000); + break; + } + // Ensure positive and at least 1 sat if rounding produced 0 + return $sats > 0 ? $sats : null; + } } diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index f527c16..4c6bf28 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -1,14 +1,27 @@
{% for item in list %} -
+
+ {% if item.kind is defined and item.kind == '9735' %} +
+ {% if zapAmounts[item.id] is defined %} + {{ zapAmounts[item.id] }} sat + {% else %} + Zap + {% endif %} +
+ {% endif %}
+ + {# Display Nostr link previews if links detected #} {% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
{% endif %} + {# Display tags if user logged in and has role ROLE_ADMIN #} + {% if is_granted('ROLE_ADMIN') %} {% if item.tags is defined and item.tags|length > 0 %} {% endif %} + {% endif %}
{% endfor %}
diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index 2d65438..4d8c749 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -16,7 +16,7 @@
+ }) }} data-nostr-publish-target="form">
@@ -37,15 +37,10 @@ class="btn btn-primary" data-nostr-publish-target="publishButton" data-action="click->nostr-publish#publish"> - Publish to Nostr + Publish
- -
- {{ form_row(form.actions.submit) }} -
- {{ form_end(form) }}