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,
],
]);
}
}