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) { 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 { $expectedTags = Nip22CommentTags::forReplyToComment($cid, $cpk, $k, $rawTags); } 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, $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 = (int) $data->kind; $allowedKinds = [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value]; if (!\in_array($kind, $allowedKinds, true)) { 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 */ // Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation. #[Route( path: '/article/d/{slug}', name: 'article-slug', requirements: ['slug' => '.+'], options: ['utf8' => true], )] public function article( $slug, EntityManagerInterface $entityManager, CacheService $cacheService, CacheItemPoolInterface $articlesCache, Converter $converter, ArticleCommentThreadLoader $commentThreadLoader ): 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); $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 = $cacheService->getMetadata($npub); $kind = $article->getKind()?->value ?? 30023; $pubkey = (string) $article->getPubkey(); $articleSlug = (string) ($article->getSlug() ?? $slug); $coordinate = $kind.':'.$pubkey.':'.$articleSlug; $eid = $article->getEventId(); $eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null; $articleTitle = (string) ($article->getTitle() ?? ''); $commentsData = null; $commentsPreloaded = false; $cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid); if (null !== $cached) { $commentsData = $this->enrichCommentDataWithReplyContext( $cached, $coordinate, $eid, $articleTitle ); $commentsPreloaded = true; } return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $cacheItem->get(), 'comments_data' => $commentsData, 'comments_preloaded' => $commentsPreloaded, ]); } /** * 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, CacheItemPoolInterface $articlesCache ): 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 { $key = new Key(); $npub = $key->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, 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(), 'comments_preloaded' => false, ]); } /** * Display latest 20 community articles */ #[Route('/articles', name: 'articles')] public function latestArticles(EntityManagerInterface $entityManager): Response { set_time_limit(300); // 5 minutes ini_set('max_execution_time', '300'); $articles = $entityManager->getRepository(Article::class) ->findBy([], ['createdAt' => 'DESC'], 20); $category = (object) [ 'title' => 'Community Articles', 'summary' => 'Latest articles from the community', ]; return $this->render('pages/category.html.twig', [ 'category' => $category, 'list' => $articles, 'sync_slug' => '', ]); } }