Browse Source

Publishing and comments

imwald
Nuša Pukšič 5 months ago
parent
commit
b6cb12e62c
  1. 26
      assets/controllers/nostr_publish_controller.js
  2. 4
      assets/styles/card.css
  3. 165
      src/Controller/ArticleController.php
  4. 2
      src/Enum/KindsEnum.php
  5. 19
      src/Form/EditorType.php
  6. 4
      src/Service/NostrClient.php
  7. 135
      src/Twig/Components/Organisms/Comments.php
  8. 20
      templates/components/Organisms/Comments.html.twig
  9. 9
      templates/pages/editor.html.twig

26
assets/controllers/nostr_publish_controller.js

@ -9,19 +9,30 @@ export default class extends Controller { @@ -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 { @@ -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

4
assets/styles/card.css

@ -54,6 +54,10 @@ h2.card-title { @@ -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;

165
src/Controller/ArticleController.php

@ -15,12 +15,14 @@ use nostriphant\NIP19\Bech32; @@ -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 @@ -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 @@ -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 @@ -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 = '<span>Error fetching preview: ' . htmlspecialchars($e->getMessage()) . '</span>';
}
}
return new Response(
$html,
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
/**
* Create new article
* @throws InvalidArgumentException
@ -234,33 +182,6 @@ class ArticleController extends AbstractController @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -502,11 +398,4 @@ class ArticleController extends AbstractController
return $data;
}
private function generateEventId(array $event): string
{
return $event['id'];
}
// ...existing code...
}

2
src/Enum/KindsEnum.php

@ -16,7 +16,7 @@ enum KindsEnum: int @@ -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

19
src/Form/EditorType.php

@ -9,8 +9,6 @@ use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; @@ -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 @@ -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')

4
src/Service/NostrClient.php

@ -432,7 +432,7 @@ class NostrClient @@ -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 @@ -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
);

135
src/Twig/Components/Organisms/Comments.php

@ -12,6 +12,8 @@ final class Comments @@ -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 @@ -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 @@ -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;
}
}

20
templates/components/Organisms/Comments.html.twig

@ -1,14 +1,27 @@ @@ -1,14 +1,27 @@
<div class="comments">
{% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == 9735 %}zap-comment{% endif %}">
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata">
<p><twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" /></p>
<p>
<twig:Molecules:UserFromNpub ident="{{ item.kind == '9735' ? zappers[item.id] : item.pubkey }}" />
</p>
<small>{{ item.created_at|date('F j Y') }}</small>
</div>
<div class="card-body">
{% if item.kind is defined and item.kind == '9735' %}
<div class="zap-amount">
{% if zapAmounts[item.id] is defined %}
<strong>{{ zapAmounts[item.id] }} sat</strong>
{% else %}
<em>Zap</em>
{% endif %}
</div>
{% endif %}
<twig:Atoms:Content content="{{ item.content }}" />
</div>
{# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3">
@ -21,6 +34,8 @@ @@ -21,6 +34,8 @@
</div>
</div>
{% 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 %}
<ul>
{% for tag in item.tags %}
@ -36,6 +51,7 @@ @@ -36,6 +51,7 @@
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>

9
templates/pages/editor.html.twig

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
<div {{ stimulus_controller('nostr-publish', {
publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-form-target="form">
}) }} data-nostr-publish-target="form">
<!-- Status messages -->
<div data-nostr-publish-target="status"></div>
@ -37,15 +37,10 @@ @@ -37,15 +37,10 @@
class="btn btn-primary"
data-nostr-publish-target="publishButton"
data-action="click->nostr-publish#publish">
Publish to Nostr
Publish
</button>
</div>
<!-- Keep the original submit button hidden for fallback -->
<div style="display: none;">
{{ form_row(form.actions.submit) }}
</div>
{{ form_end(form) }}
</div>

Loading…
Cancel
Save