'^(naddr1[0-9a-zA-Z]+)$'])] public function naddr(NostrClient $nostrClient, $naddr) { set_time_limit(120); // 2 minutes $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', requirements: ['slug' => '.+'])] public function article( $slug, EntityManagerInterface $entityManager, RedisCacheService $redisCacheService, CacheItemPoolInterface $articlesCache, Converter $converter, HighlightService $highlightService ): 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(); }); } $article = $articles[0]; $cacheKey = 'article_' . $article->getEventId(); $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($article->getPubkey()); // determine whether the logged-in user is the author $canEdit = false; $user = $this->getUser(); if ($user) { try { $currentPubkey = $key->convertToHex($user->getUserIdentifier()); $canEdit = ($currentPubkey === $article->getPubkey()); } catch (\Throwable $e) { $canEdit = false; } } $canonical = $this->generateUrl('article-slug', ['slug' => $article->getSlug()], 0); // Fetch highlights using the caching service $highlights = []; try { $articleCoordinate = '30023:' . $article->getPubkey() . ':' . $article->getSlug(); error_log('ArticleController: Looking for highlights with coordinate: ' . $articleCoordinate); $highlights = $highlightService->getHighlightsForArticle($articleCoordinate); error_log('ArticleController: Found ' . count($highlights) . ' highlights'); } catch (\Exception $e) { // Log but don't fail the page if highlights can't be fetched // Highlights are optional enhancement error_log('ArticleController: Failed to fetch highlights: ' . $e->getMessage()); } return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $cacheItem->get(), 'canEdit' => $canEdit, 'canonical' => $canonical, 'highlights' => $highlights ]); } /** * Create new article * @throws \Exception */ #[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/edit/{slug}', name: 'editor-edit-slug')] public function newArticle( Request $request, NostrClient $nostrClient, EntityManagerInterface $entityManager, NostrEventParser $eventParser, $slug = null ): Response { $advancedMetadata = null; if (!$slug) { $article = new Article(); $article->setKind(KindsEnum::LONGFORM); $article->setCreatedAt(new \DateTimeImmutable()); $formAction = $this->generateUrl('editor-create'); } else { $formAction = $this->generateUrl('editor-edit-slug', ['slug' => $slug]); $repository = $entityManager->getRepository(Article::class); $slug = urldecode($slug); $articles = $repository->findBy(['slug' => $slug]); if (count($articles) === 0) { throw $this->createNotFoundException('The article could not be found'); } // Sort by createdAt, get latest revision usort($articles, function ($a, $b) { return $b->getCreatedAt() <=> $a->getCreatedAt(); }); $article = array_shift($articles); // Parse advanced metadata from the raw event if available if ($article->getRaw()) { $tags = $article->getRaw()['tags'] ?? []; $advancedMetadata = $eventParser->parseAdvancedMetadata($tags); } } $recentArticles = []; $drafts = []; $user = $this->getUser(); if (!!$user) { $key = new Key(); $currentPubkey = $key->convertToHex($user->getUserIdentifier()); $recentArticles = $entityManager->getRepository(Article::class) ->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC'], 5); // Collapse by slug, keep only latest revision $recentArticles = array_reduce($recentArticles, function ($carry, $item) { if (!isset($carry[$item->getSlug()])) { $carry[$item->getSlug()] = $item; } return $carry; }); $recentArticles = array_values($recentArticles ?? []); // get drafts // look for drafts on relays first, grab latest 5 from there // one week ago $since = new \DateTime(); $aWeekAgo = $since->sub(new \DateInterval('P1D'))->getTimestamp(); $nostrClient->getLongFormContentForPubkey($currentPubkey, $aWeekAgo, KindsEnum::LONGFORM_DRAFT->value); $drafts = $entityManager->getRepository(Article::class) ->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM_DRAFT], ['createdAt' => 'DESC'], 5); if ($article->getPubkey() === null) { $article->setPubkey($currentPubkey); } } $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); // Populate advanced metadata form data if ($advancedMetadata) { $form->get('advancedMetadata')->setData($advancedMetadata); } $form->handleRequest($request); // load template with content editor return $this->render('pages/editor.html.twig', [ 'article' => $article, 'form' => $form->createView(), 'recentArticles' => $recentArticles, 'drafts' => $drafts, ]); } /** * 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, CacheItemPoolInterface $articlesCache, CsrfTokenManagerInterface $csrfTokenManager, LoggerInterface $logger, NostrEventParser $eventParser ): JsonResponse { try { // Get JSON data $data = json_decode($request->getContent(), true); if (!$data || !isset($data['event'])) { return new JsonResponse(['error' => 'Invalid request data'], 400); } /* @var array $signedEvent */ $signedEvent = $data['event']; // 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); } $formData = $data['formData'] ?? []; // Extract article data from the signed event $articleData = $this->extractArticleDataFromEvent($signedEvent, $formData); // Create new article $article = new Article(); $article->setPubkey($signedEvent['pubkey']); $article->setKind(KindsEnum::LONGFORM); $article->setEventId($signedEvent['id']); $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()); // Parse and store advanced metadata $advancedMetadata = $eventParser->parseAdvancedMetadata($signedEvent['tags']); $article->setAdvancedMetadata([ 'doNotRepublish' => $advancedMetadata->doNotRepublish, 'license' => $advancedMetadata->getLicenseValue(), 'zapSplits' => array_map(function($split) { return [ 'recipient' => $split->recipient, 'relay' => $split->relay, 'weight' => $split->weight, ]; }, $advancedMetadata->zapSplits), 'contentWarning' => $advancedMetadata->contentWarning, 'expirationTimestamp' => $advancedMetadata->expirationTimestamp, 'isProtected' => $advancedMetadata->isProtected, ]); // Save to database $entityManager->persist($article); $entityManager->flush(); // Clear relevant caches $cacheKey = 'article_' . $article->getEventId(); $articlesCache->delete($cacheKey); // Publish to Nostr relays try { $nostrClient->publishEvent($eventObj, []); } 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 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; } }