query->getString('coordinate'); if ($coordinate === '' || !self::isValidNostrCoordinate($coordinate)) { return new Response('Invalid coordinate', Response::HTTP_BAD_REQUEST); } $articleEventId = $request->query->getString('e'); if ($articleEventId !== '' && !self::isValidHexEventId($articleEventId)) { return new Response('Invalid event id', Response::HTTP_BAD_REQUEST); } if ($articleEventId === '') { $articleEventId = null; } $articleTitle = $request->query->getString('title'); if (strlen($articleTitle) > 200) { $articleTitle = substr($articleTitle, 0, 200); } $logger->info('http.fragment.comments_start', [ 'coordinate' => $coordinate, 'article_event_hex' => $articleEventId, ]); $headers = [ 'Content-Type' => 'text/html; charset=UTF-8', 'Cache-Control' => 'private, max-age=60', ]; try { $data = $loader->load($coordinate, $articleEventId); $data = $this->enrichCommentDataWithReplyContext( $data, $coordinate, $articleEventId, $articleTitle ); $logger->info('http.fragment.comments_after_load', [ 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), ]); $tRender = microtime(true); $response = $this->render('components/Organisms/Comments.html.twig', $data, new Response( '', Response::HTTP_OK, $headers )); $logger->info('http.fragment.comments_response', [ 'total_elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), 'render_elapsed_ms' => (int) round((microtime(true) - $tRender) * 1000), ]); return $response; } catch (\Throwable $e) { $logger->error('http.fragment.comments_exception', [ 'message' => $e->getMessage(), 'exception_class' => \get_class($e), 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), ]); return new Response('
', Response::HTTP_OK, $headers); } } /** * Adds `comment_reply_context` for the reply composer (same data as the HTML fragment, used for full-page SSR when cache hits). * * @param array{ * list: array, * quotes: array, * commentLinks: array>, * quoteLinks: array>, * processedContent: array * } $data * * @return array{ * list: array, * quotes: array, * commentLinks: array>, * quoteLinks: array>, * processedContent: array, * comment_reply_context: array{ * can_publish: bool, * coordinate: string, * article_event_id: ?string, * parent_kind: int, * rows: array>, * fragment_url: string * } * } */ private function enrichCommentDataWithReplyContext( array $data, string $coordinate, ?string $articleEventId, string $articleTitle ): array { $coordparts = explode(':', $coordinate, 3); $articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023; $articleAuthorPubkey = strtolower(trim((string) ($coordparts[1] ?? ''))); $articleReplyTags = null; if ($articleAuthorPubkey !== '' && 64 === \strlen($articleAuthorPubkey) && ctype_xdigit($articleAuthorPubkey)) { $articleReplyTags = Nip22CommentTags::forReplyToArticle($coordinate, $articleAuthorPubkey); } $parentIdForNaddr = str_repeat('0', 64); if ($articleEventId !== null && 64 === \strlen($articleEventId) && ctype_xdigit($articleEventId)) { $articleParentId = $articleEventId; } else { $articleParentId = $parentIdForNaddr; } $threadReplyRows = []; $userMayReply = $this->isGranted('ROLE_USER'); if ($userMayReply && $articleReplyTags !== null) { $threadReplyRows[] = [ 'mode' => 'article', 'blurbLabel' => $articleTitle !== '' ? $articleTitle : 'Article', 'parentKind' => $articleKind, 'parentId' => $articleParentId, 'authorPubkey' => $articleAuthorPubkey, 'expectedTags' => $articleReplyTags, ]; } if ($userMayReply) { /** @var array $list */ $list = $data['list'] ?? []; foreach ($list as $row) { if (!\is_object($row)) { continue; } $k = (int) ($row->kind ?? 0); if ($k !== KindsEnum::COMMENTS->value && $k !== KindsEnum::TEXT_NOTE->value) { continue; } $cid = strtolower(trim((string) ($row->id ?? ''))); $cpk = strtolower(trim((string) ($row->pubkey ?? ''))); if ($cid === '' || 64 !== \strlen($cid) || !ctype_xdigit($cid)) { continue; } if ($cpk === '' || 64 !== \strlen($cpk) || !ctype_xdigit($cpk)) { continue; } $rawTags = json_decode(json_encode($row->tags ?? []), true); if (!\is_array($rawTags)) { $rawTags = []; } $forSnippet = (string) ($row->unfold_body ?? $row->content ?? ''); $snippet = trim($forSnippet); if (strlen($snippet) > 120) { $snippet = substr($snippet, 0, 117).'…'; } if ($snippet === '') { $snippet = 'Comment'; } try { if ($k === KindsEnum::COMMENTS->value) { $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); } else { $expectedTags = Nip10Kind1ArticleReplyTags::forReplyToKind1( $cid, $cpk, $rawTags, $coordinate, $articleEventId ); } } catch (\Throwable) { continue; } $threadReplyRows[] = [ 'mode' => 'comment', 'blurbLabel' => $snippet, 'parentKind' => $k, 'parentId' => $cid, 'authorPubkey' => $cpk, 'expectedTags' => $expectedTags, ]; } } $fragmentQuery = ['coordinate' => $coordinate, 'title' => $articleTitle]; if ($articleEventId !== null) { $fragmentQuery['e'] = $articleEventId; } $data['comment_reply_context'] = [ 'can_publish' => $userMayReply, 'coordinate' => $coordinate, 'article_event_id' => $articleEventId, 'parent_kind' => $articleKind, 'rows' => $threadReplyRows, 'fragment_url' => $this->generateUrl('article_comments_fragment', $fragmentQuery), ]; return $data; } private static function isValidNostrCoordinate(string $coordinate): bool { $parts = explode(':', $coordinate, 3); if (\count($parts) !== 3) { return false; } [$kind, $pubkey, $d] = $parts; if ($d === '' || !ctype_digit((string) $kind)) { return false; } return strlen($pubkey) === 64 && ctype_xdigit($pubkey); } private static function isValidHexEventId(string $id): bool { return strlen($id) === 64 && ctype_xdigit($id); } /** * @throws \Exception */ #[Route('/article/{naddr}', name: 'article-naddr')] public function naddr(NostrClient $nostrClient, Nip19Codec $nip19, NostrKeyHelper $nostrKeyHelper, $naddr) { $decoded = $nip19->decode($naddr); if ($decoded->type !== 'naddr') { throw new \Exception('Invalid naddr'); } $data = $decoded->data; $slug = $data->identifier; $relays = $data->relays; $author = $data->pubkey; $kind = (int) $data->kind; if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { throw new \Exception('Not a long form article'); } $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); if ($slug) { $npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $author); return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY); } throw new \Exception('No article.'); } /** * @throws InvalidArgumentException|CommonMarkException */ // Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation. #[Route( path: '/p/{npub}/d/{slug}', name: 'article', requirements: ['npub' => '^npub1.*', 'slug' => '.+'], options: ['utf8' => true], )] public function article( string $npub, string $slug, EntityManagerInterface $entityManager, CacheService $cacheService, ArticleCommentThreadLoader $commentThreadLoader, ArticleBodyHtmlRenderer $articleBodyHtmlRenderer, NostrKeyHelper $nostrKeyHelper, ): Response { $article = $this->loadLatestArticleBySlug($entityManager, $slug); if ($article === null) { throw $this->createNotFoundException('The article could not be found'); } if ($nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) { throw $this->createNotFoundException('The article could not be found'); } return $this->renderArticle( $article, $cacheService, $commentThreadLoader, $articleBodyHtmlRenderer, $nostrKeyHelper ); } /** * Legacy: /article/d/{slug} → 301 to /p/{npub}/d/{slug} (NIP-33 with author npub in path). */ #[Route( path: '/article/d/{slug}', name: 'article-legacy-redirect', requirements: ['slug' => '.+'], options: ['utf8' => true], )] public function articleLegacyRedirect( string $slug, EntityManagerInterface $entityManager, NostrKeyHelper $nostrKeyHelper, ): Response { $article = $this->loadLatestArticleBySlug($entityManager, $slug); if ($article === null) { throw $this->createNotFoundException('The article could not be found'); } $npub = $nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey()); return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY); } private function loadLatestArticleBySlug(EntityManagerInterface $entityManager, string $slug): ?Article { /** @var ArticleRepository $repository */ $repository = $entityManager->getRepository(Article::class); return $repository->findLatestBySlug($slug); } private function renderArticle( Article $article, CacheService $cacheService, ArticleCommentThreadLoader $commentThreadLoader, ArticleBodyHtmlRenderer $articleBodyHtmlRenderer, NostrKeyHelper $nostrKeyHelper, ): Response { $t = PhpExecutionTime::NOSTR_BOUND_WEB_SEC; set_time_limit($t); ini_set('max_execution_time', (string) $t); $html = $articleBodyHtmlRenderer->renderForArticle($article); $npub = $nostrKeyHelper->convertPublicKeyToBech32($article->getPubkey()); $author = $cacheService->getMetadata($npub); $kind = $article->getKind()?->value ?? 30023; $pubkey = (string) $article->getPubkey(); $articleSlug = (string) $article->getSlug(); $coordinate = $kind.':'.$pubkey.':'.$articleSlug; $eid = $article->getEventId(); $eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null; $articleTitle = (string) ($article->getTitle() ?? ''); $commentsData = null; $commentsPreloaded = false; $commentReplyContext = $this->buildArticleReplyContext($coordinate, $eid, $articleTitle); $cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid); if (null !== $cached) { $commentsData = $this->enrichCommentDataWithReplyContext( $cached, $coordinate, $eid, $articleTitle ); $commentReplyContext = $commentsData['comment_reply_context'] ?? $commentReplyContext; $commentsPreloaded = true; } return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $html, 'comments_data' => $commentsData, 'comments_preloaded' => $commentsPreloaded, 'comment_reply_context' => $commentReplyContext, ]); } /** * Base article-level reply context so the top "Reply" button can render before async comments load. * * @return array{ * can_publish: bool, * coordinate: string, * article_event_id: ?string, * parent_kind: int, * rows: array>, * fragment_url: string * } */ private function buildArticleReplyContext(string $coordinate, ?string $articleEventId, string $articleTitle): array { $base = [ 'list' => [], 'quotes' => [], 'commentLinks' => [], 'quoteLinks' => [], 'processedContent' => [], ]; $enriched = $this->enrichCommentDataWithReplyContext($base, $coordinate, $articleEventId, $articleTitle); return $enriched['comment_reply_context']; } /** * 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, CacheService $cacheService, NostrKeyHelper $nostrKeyHelper, ): Response { $data = $request->getContent(); $descriptor = json_decode($data); if (!\is_object($descriptor) || !isset($descriptor->type)) { return new Response( 'Invalid preview request.', Response::HTTP_OK, ['Content-Type' => 'text/html; charset=UTF-8'] ); } $html = ''; try { if ($descriptor->type === 'nprofile') { if (!isset($descriptor->decoded) || !\is_string($descriptor->decoded)) { $html = 'Profile preview unavailable.'; } else { $hint = json_decode($descriptor->decoded); if (!\is_object($hint) || !isset($hint->pubkey)) { $html = 'Profile preview unavailable.'; } else { $npub = $nostrKeyHelper->convertPublicKeyToBech32($hint->pubkey); $metadata = $cacheService->getMetadata($npub); $metadata->npub = $npub; $metadata->pubkey = $hint->pubkey; $metadata->type = 'nprofile'; $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ 'preview' => $metadata, ]); } } } elseif (!isset($descriptor->decoded)) { $html = 'Preview unavailable (missing data).'; } else { try { $previewData = $nostrClient->getEventFromDescriptor($descriptor); } catch (\Throwable $e) { $previewData = null; $html = 'Error fetching preview: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').''; } if ($html === '' && $previewData === null) { $html = 'No event found on the default relay for this preview.'; } elseif ($html === '' && \is_object($previewData)) { $previewData->type = $descriptor->type; $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ 'preview' => $previewData, ]); } } } catch (\Throwable $e) { $html = 'Preview error: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').''; } return new Response( $html, Response::HTTP_OK, ['Content-Type' => 'text/html; charset=UTF-8'] ); } /** * 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, NostrKeyHelper $nostrKeyHelper, 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(); $currentPubkey = $nostrKeyHelper->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, NostrKeyHelper $nostrKeyHelper): Response { $user = $this->getUser(); $currentPubkey = $nostrKeyHelper->convertToHex($user->getUserIdentifier()); $cacheKey = 'article_' . $currentPubkey . '_' . $d; $cacheItem = $articlesCache->getItem($cacheKey); $article = $cacheItem->get(); $content = $converter->convertToHtml($article->getContent()); $previewNpub = $nostrKeyHelper->convertPublicKeyToBech32($currentPubkey); return $this->render('pages/article.html.twig', [ 'article' => $article, 'content' => $content, 'author' => $user->getMetadata(), 'npub' => $previewNpub, 'comments_preloaded' => false, ]); } /** * Display latest community articles (paginated). */ #[Route('/articles', name: 'articles')] public function latestArticles(Request $request, EntityManagerInterface $entityManager): Response { $t = PhpExecutionTime::LIGHT_WEB_SEC; set_time_limit($t); ini_set('max_execution_time', (string) $t); $perPage = 25; $page = max(1, $request->query->getInt('page', 1)); $offset = ($page - 1) * $perPage; $repo = $entityManager->getRepository(Article::class); $total = $repo->count([]); $lastPage = max(1, (int) ceil($total / $perPage)); if ($page > $lastPage) { $page = $lastPage; $offset = ($page - 1) * $perPage; } $articles = $repo->findBy([], ['createdAt' => 'DESC'], $perPage, $offset); $category = (object) [ 'title' => 'Community Articles', 'summary' => 'Latest articles from the community', ]; return $this->render('pages/category.html.twig', [ 'category' => $category, 'list' => $articles, 'sync_slug' => '', 'pagination' => [ 'page' => $page, 'per_page' => $perPage, 'total' => $total, 'last_page' => $lastPage, ], ]); } }