You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
512 lines
19 KiB
512 lines
19 KiB
<?php |
|
|
|
namespace App\Controller; |
|
|
|
use App\Entity\Article; |
|
use App\Enum\KindsEnum; |
|
use App\Form\EditorType; |
|
use App\Service\NostrClient; |
|
use App\Service\RedisCacheService; |
|
use App\Util\CommonMark\Converter; |
|
use Doctrine\ORM\EntityManagerInterface; |
|
use League\CommonMark\Exception\CommonMarkException; |
|
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; |
|
use nostriphant\NIP19\Bech32; |
|
use nostriphant\NIP19\Data\NAddr; |
|
use Psr\Cache\CacheItemPoolInterface; |
|
use Psr\Cache\InvalidArgumentException; |
|
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\String\Slugger\AsciiSlugger; |
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; |
|
use Symfony\Component\Workflow\WorkflowInterface; |
|
|
|
class ArticleController extends AbstractController |
|
{ |
|
/** |
|
* @throws \Exception |
|
*/ |
|
#[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-z]{59})$'])] |
|
public function naddr(NostrClient $nostrClient, $naddr) |
|
{ |
|
$decoded = new Bech32($naddr); |
|
|
|
if ($decoded->type !== 'naddr') { |
|
throw new \Exception('Invalid naddr'); |
|
} |
|
|
|
/** @var NAddr $data */ |
|
$data = $decoded->data; |
|
$slug = $data->identifier; |
|
$relays = $data->relays; |
|
$author = $data->pubkey; |
|
$kind = $data->kind; |
|
|
|
if ($kind !== KindsEnum::LONGFORM->value) { |
|
throw new \Exception('Not a long form article'); |
|
} |
|
|
|
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); |
|
if ($slug) { |
|
return $this->redirectToRoute('article-slug', ['slug' => $slug]); |
|
} |
|
|
|
throw new \Exception('No article.'); |
|
} |
|
|
|
/** |
|
* @throws InvalidArgumentException|CommonMarkException |
|
*/ |
|
#[Route('/article/d/{slug}', name: 'article-slug')] |
|
public function article( |
|
$slug, |
|
EntityManagerInterface $entityManager, |
|
RedisCacheService $redisCacheService, |
|
CacheItemPoolInterface $articlesCache, |
|
Converter $converter |
|
): Response |
|
{ |
|
|
|
set_time_limit(300); // 5 minutes |
|
ini_set('max_execution_time', '300'); |
|
|
|
$article = null; |
|
// check if an item with same eventId already exists in the db |
|
$repository = $entityManager->getRepository(Article::class); |
|
// slug might be url encoded, decode it |
|
$slug = urldecode($slug); |
|
$articles = $repository->findBy(['slug' => $slug]); |
|
|
|
$revisions = count($articles); |
|
|
|
if ($revisions === 0) { |
|
throw $this->createNotFoundException('The article could not be found'); |
|
} |
|
|
|
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]; |
|
} |
|
|
|
$cacheKey = 'article_' . $article->getId(); |
|
$cacheItem = $articlesCache->getItem($cacheKey); |
|
if (!$cacheItem->isHit()) { |
|
$cacheItem->set($converter->convertToHtml($article->getContent())); |
|
$articlesCache->save($cacheItem); |
|
} |
|
|
|
$key = new Key(); |
|
$npub = $key->convertPublicKeyToBech32($article->getPubkey()); |
|
$author = $redisCacheService->getMetadata($npub); |
|
|
|
|
|
return $this->render('pages/article.html.twig', [ |
|
'article' => $article, |
|
'author' => $author, |
|
'npub' => $npub, |
|
'content' => $cacheItem->get(), |
|
]); |
|
} |
|
|
|
/** |
|
* 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 |
|
* @throws \Exception |
|
*/ |
|
#[Route('/article-editor/create', name: 'editor-create')] |
|
#[Route('/article-editor/edit/{id}', name: 'editor-edit')] |
|
public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, |
|
WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response |
|
{ |
|
if (!$article) { |
|
$article = new Article(); |
|
$article->setKind(KindsEnum::LONGFORM); |
|
$article->setCreatedAt(new \DateTimeImmutable()); |
|
$formAction = $this->generateUrl('editor-create'); |
|
} else { |
|
$formAction = $this->generateUrl('editor-edit', ['id' => $article->getId()]); |
|
} |
|
|
|
$form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); |
|
$form->handleRequest($request); |
|
|
|
// Step 3: Check if the form is submitted and valid |
|
if ($form->isSubmitted() && $form->isValid()) { |
|
$user = $this->getUser(); |
|
$key = new Key(); |
|
$currentPubkey = $key->convertToHex($user->getUserIdentifier()); |
|
|
|
if ($article->getPubkey() === null) { |
|
$article->setPubkey($currentPubkey); |
|
} |
|
|
|
// Check which button was clicked |
|
if ($form->getClickedButton() === $form->get('actions')->get('submit')) { |
|
// Save button was clicked, handle the "Publish" action |
|
$this->addFlash('success', 'Product published!'); |
|
} elseif ($form->getClickedButton() === $form->get('actions')->get('draft')) { |
|
// Save and Publish button was clicked, handle the "Draft" action |
|
$this->addFlash('success', 'Product saved as draft!'); |
|
} elseif ($form->getClickedButton() === $form->get('actions')->get('preview')) { |
|
// Preview button was clicked, handle the "Preview" action |
|
// construct slug from title and save to tags |
|
$slugger = new AsciiSlugger(); |
|
$slug = $slugger->slug($article->getTitle())->lower(); |
|
$article->setSig(''); // clear the sig |
|
$article->setSlug($slug); |
|
$cacheKey = 'article_' . $currentPubkey . '_' . $article->getSlug(); |
|
$cacheItem = $articlesCache->getItem($cacheKey); |
|
$cacheItem->set($article); |
|
$articlesCache->save($cacheItem); |
|
|
|
return $this->redirectToRoute('article-preview', ['d' => $article->getSlug()]); |
|
} |
|
} |
|
|
|
// load template with content editor |
|
return $this->render('pages/editor.html.twig', [ |
|
'article' => $article, |
|
'form' => $this->createForm(EditorType::class, $article)->createView(), |
|
]); |
|
} |
|
|
|
/** |
|
* 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 |
|
*/ |
|
#[Route('/api/article/publish', name: 'api-article-publish', methods: ['POST'])] |
|
public function publishNostrEvent( |
|
Request $request, |
|
EntityManagerInterface $entityManager, |
|
NostrClient $nostrClient, |
|
WorkflowInterface $articlePublishingWorkflow, |
|
CsrfTokenManagerInterface $csrfTokenManager |
|
): JsonResponse { |
|
try { |
|
// Verify CSRF token |
|
$csrfToken = $request->headers->get('X-CSRF-TOKEN'); |
|
if (!$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('nostr_publish', $csrfToken))) { |
|
return new JsonResponse(['error' => 'Invalid CSRF token'], 403); |
|
} |
|
|
|
// Get JSON data |
|
$data = json_decode($request->getContent(), true); |
|
if (!$data || !isset($data['event'])) { |
|
return new JsonResponse(['error' => 'Invalid request data'], 400); |
|
} |
|
|
|
$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); |
|
} |
|
|
|
// Check if user is authenticated and matches the event pubkey |
|
$user = $this->getUser(); |
|
if (!$user) { |
|
return new JsonResponse(['error' => 'User not authenticated'], 401); |
|
} |
|
|
|
$key = new Key(); |
|
$currentPubkey = $key->convertToHex($user->getUserIdentifier()); |
|
|
|
if ($signedEvent['pubkey'] !== $currentPubkey) { |
|
return new JsonResponse(['error' => 'Event pubkey does not match authenticated user'], 403); |
|
} |
|
|
|
// 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)); |
|
$article->setSlug($articleData['slug']); |
|
$article->setTitle($articleData['title']); |
|
$article->setSummary($articleData['summary']); |
|
$article->setContent($articleData['content']); |
|
$article->setImage($articleData['image']); |
|
$article->setTopics($articleData['topics']); |
|
$article->setSig($signedEvent['sig']); |
|
$article->setRaw($signedEvent); |
|
$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]; |
|
} |
|
} |
|
} |
|
|
|
// Fallback to default relays if no user relays found |
|
if (empty($relays)) { |
|
$relays = [ |
|
'wss://relay.damus.io', |
|
'wss://relay.primal.net', |
|
'wss://nos.lol' |
|
]; |
|
} |
|
|
|
$nostrClient->publishEvent($eventObj, $relays); |
|
} catch (\Exception $e) { |
|
// Log error but don't fail the request - article is saved locally |
|
error_log('Failed to publish to Nostr relays: ' . $e->getMessage()); |
|
} |
|
|
|
return new JsonResponse([ |
|
'success' => true, |
|
'message' => 'Article published successfully', |
|
'articleId' => $article->getId(), |
|
'slug' => $article->getSlug() |
|
]); |
|
|
|
} catch (\Exception $e) { |
|
return new JsonResponse([ |
|
'error' => 'Publishing failed: ' . $e->getMessage() |
|
], 500); |
|
} |
|
} |
|
|
|
private function validateNostrEvent(array $event): void |
|
{ |
|
$requiredFields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; |
|
|
|
foreach ($requiredFields as $field) { |
|
if (!isset($event[$field])) { |
|
throw new \InvalidArgumentException("Missing required field: $field"); |
|
} |
|
} |
|
|
|
if ($event['kind'] !== 30023) { |
|
throw new \InvalidArgumentException('Invalid event kind. Expected 30023 for long-form content.'); |
|
} |
|
|
|
// Validate d tag exists (required for NIP-33) |
|
$dTagFound = false; |
|
foreach ($event['tags'] as $tag) { |
|
if (is_array($tag) && count($tag) >= 2 && $tag[0] === 'd') { |
|
$dTagFound = true; |
|
break; |
|
} |
|
} |
|
|
|
if (!$dTagFound) { |
|
throw new \InvalidArgumentException('Missing required "d" tag for replaceable event'); |
|
} |
|
} |
|
|
|
private function verifyNostrSignature(array $event): bool |
|
{ |
|
try { |
|
// Reconstruct the event ID |
|
$serializedEvent = json_encode([ |
|
0, |
|
$event['pubkey'], |
|
$event['created_at'], |
|
$event['kind'], |
|
$event['tags'], |
|
$event['content'] |
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
|
|
|
$eventId = hash('sha256', $serializedEvent); |
|
|
|
// Verify the event ID matches |
|
if ($eventId !== $event['id']) { |
|
return false; |
|
} |
|
|
|
return (new SchnorrSignature())->verify($event['pubkey'], $event['sig'], $event['id']); |
|
} catch (\Exception $e) { |
|
return false; |
|
} |
|
} |
|
|
|
private function extractArticleDataFromEvent(array $event, array $formData): array |
|
{ |
|
$data = [ |
|
'title' => '', |
|
'summary' => '', |
|
'content' => $event['content'], |
|
'image' => '', |
|
'topics' => [], |
|
'slug' => '' |
|
]; |
|
|
|
// Extract data from tags |
|
foreach ($event['tags'] as $tag) { |
|
if (!is_array($tag) || count($tag) < 2) continue; |
|
|
|
switch ($tag[0]) { |
|
case 'd': |
|
$data['slug'] = $tag[1]; |
|
break; |
|
case 'title': |
|
$data['title'] = $tag[1]; |
|
break; |
|
case 'summary': |
|
$data['summary'] = $tag[1]; |
|
break; |
|
case 'image': |
|
$data['image'] = $tag[1]; |
|
break; |
|
case 't': |
|
$data['topics'][] = $tag[1]; |
|
break; |
|
} |
|
} |
|
|
|
// Fallback to form data if not found in tags |
|
if (empty($data['title']) && !empty($formData['title'])) { |
|
$data['title'] = $formData['title']; |
|
} |
|
if (empty($data['summary']) && !empty($formData['summary'])) { |
|
$data['summary'] = $formData['summary']; |
|
} |
|
|
|
return $data; |
|
} |
|
|
|
private function generateEventId(array $event): string |
|
{ |
|
return $event['id']; |
|
} |
|
|
|
// ...existing code... |
|
|
|
}
|
|
|