diff --git a/.gitignore b/.gitignore index 709b5e1..4bd5aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,6 @@ /publication/ ###> strfry relay ### -/infra/strfry/data/ +/docker/strfry/data/ ###< strfry relay ### diff --git a/assets/styles/01-base/theme.css b/assets/styles/01-base/theme.css index 004c9fc..33e0300 100644 --- a/assets/styles/01-base/theme.css +++ b/assets/styles/01-base/theme.css @@ -20,10 +20,8 @@ --brand-font: 'Lobster', serif; /* A classic, refined branding font */ --brand-color: white; + --color-accent-strong: #E1B574; /* warm goldenrod (strong accent) */ --color-accent: #8FCB7E; /* fresh moss (main accent) */ - --color-accent-strong: #B98BDC; /* lilac pop for headings/CTAs */ - --color-accent-teal: #78C8BD; /* teal for tags/pills */ - --color-accent-warm: #E1B574; /* warm highlight (badges/notes) */ --color-accent-600: #7FBF70; --color-accent-500: #8FCB7E; --color-accent-400: #A5D692; diff --git a/assets/styles/02-layout/layout.css b/assets/styles/02-layout/layout.css index f175d26..fa0105d 100644 --- a/assets/styles/02-layout/layout.css +++ b/assets/styles/02-layout/layout.css @@ -290,3 +290,9 @@ section{ position: relative; padding: var(--section-spacing) var(--spacing-3); } line-height: 2; white-space: normal !important; } + +.stat-label { + font-size: 0.9rem; + color: var(--color-text-mid); + font-weight: bold; +} diff --git a/assets/styles/03-components/button.css b/assets/styles/03-components/button.css index e7f9a3f..2c8ac7a 100644 --- a/assets/styles/03-components/button.css +++ b/assets/styles/03-components/button.css @@ -4,26 +4,27 @@ */ button, .btn, a.btn { - background-color: var(--color-primary); + background: var(--color-primary); color: var(--color-text-contrast); border: 2px solid var(--color-primary); - padding: var(--button-padding-y) var(--button-padding-x); - text-transform: uppercase; - font-weight: bold; + padding: 0.75em 1.5em; + font-family: var(--font-family), sans-serif; + font-size: 1rem; + font-weight: 600; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); cursor: pointer; - transition: background-color 0.3s ease, color 0.3s ease; - border-radius: 0; /* Sharp edges */ } -button:hover, .btn:hover { - background-color: var(--color-bg); - color: var(--color-primary); +button:hover, .btn:hover, a.btn:hover { + background: var(--color-accent); + color: var(--color-text); + border: 2px solid var(--color-accent); } -button:active, .btn:active { - background-color: var(--color-primary); - color: var(--color-text); - border-color: var(--color-text); +button:active, .btn:active, a.btn:active { + background: var(--color-primary); + border: 2px solid var(--color-primary); } a.btn, a.btn:hover, a.btn:active { diff --git a/assets/styles/03-components/nostr-previews.css b/assets/styles/03-components/nostr-previews.css index de7e604..97798ef 100644 --- a/assets/styles/03-components/nostr-previews.css +++ b/assets/styles/03-components/nostr-previews.css @@ -4,6 +4,10 @@ * Converted from SCSS to plain CSS */ +.nostr-article-preview.card { + margin: 0; +} + .nostr-preview { margin-top: var(--spacing-2); } @@ -18,11 +22,6 @@ border-left-color: #00b894; } -.nostr-preview .card-title { - margin-bottom: var(--spacing-2); - font-size: 1rem; -} - .nostr-preview .card-text { font-size: 0.9rem; } @@ -42,6 +41,22 @@ padding-right: var(--spacing-2); } +/* Article preview card */ +.nostr-article-preview .article-preview-image img { + max-height: 200px; + width: 100%; + object-fit: cover; +} + +.nostr-article-preview .card-title a { + color: var(--color-text); + transition: color 0.2s; +} + +.nostr-article-preview .card-title a:hover { + color: var(--color-primary); +} + /* Style for nostr links in text */ .nostr-link { color: #6c5ce7; diff --git a/assets/styles/03-components/video-event.css b/assets/styles/03-components/video-event.css index de3cbad..51b27db 100644 --- a/assets/styles/03-components/video-event.css +++ b/assets/styles/03-components/video-event.css @@ -43,7 +43,7 @@ .content-warning { padding: var(--spacing-3); - background-color: var(--color-accent-warm); + background-color: var(--color-accent-strong); border: 1px solid var(--color-accent-600); border-radius: 4px; margin-bottom: var(--spacing-3); diff --git a/assets/styles/04-pages/admin.css b/assets/styles/04-pages/admin.css index b1c6104..67f6850 100644 --- a/assets/styles/04-pages/admin.css +++ b/assets/styles/04-pages/admin.css @@ -129,7 +129,7 @@ .content-warning { padding: 1rem; - background-color: var(--color-accent-warm); + background-color: var(--color-accent-strong); border: 1px solid var(--color-accent-600); border-radius: 0.25rem; margin-bottom: 1rem; diff --git a/assets/styles/04-pages/highlights.css b/assets/styles/04-pages/highlights.css index 8098a22..52d4f8a 100644 --- a/assets/styles/04-pages/highlights.css +++ b/assets/styles/04-pages/highlights.css @@ -11,7 +11,7 @@ } .highlight-card { - background: var(--color-bg-secondary, #f9f9f9); + background: var(--color-bg-light); padding: var(--spacing-3); transition: transform 0.2s; margin-bottom: 2rem; @@ -32,18 +32,18 @@ align-items: center; gap: 0.5rem; font-size: 0.9rem; - color: var(--color-text-secondary, #666); + color: var(--color-text-mid); } .highlight-date { font-size: 0.85rem; - color: var(--color-text-muted, #999); + color: var(--color-text-mid); } .highlight-content { font-size: 1.1rem; line-height: 1.3; - color: var(--color-text-primary, #333); + color: var(--color-text); } .highlight-mark { @@ -61,25 +61,29 @@ padding-top: 1rem; } +.highlight-footer .article-preview { + margin-top: 0.5rem; +} + .article-reference { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; - color: var(--color-link, #0066cc); + color: var(--color-accent-strong); text-decoration: none; transition: color 0.2s; } .article-reference:hover { - color: var(--color-link-hover, #004499); + color: var(--color-primary); text-decoration: underline; } .no-highlights { text-align: center; padding: 4rem 2rem; - color: var(--color-text-secondary, #666); + color: var(--color-text-mid); font-size: 1.1rem; } @@ -95,7 +99,7 @@ justify-content: space-between; align-items: center; padding: 1rem; - background: var(--color-bg-secondary, #f9f9f9); + background: var(--color-bg-light); margin-bottom: 1.5rem; } @@ -109,27 +113,24 @@ } .article-highlight.visible { - background: linear-gradient(to bottom, - rgba(255, 237, 74, 0.3) 0%, - rgba(255, 237, 74, 0.5) 100%); - border-bottom: 2px solid rgba(255, 200, 0, 0.6); + background: var(--color-accent-300); + border-bottom-color: var(--color-accent-300); } .article-highlight.visible:hover { - background: linear-gradient(to bottom, - rgba(255, 237, 74, 0.5) 0%, - rgba(255, 237, 74, 0.7) 100%); - border-bottom-color: rgba(255, 200, 0, 0.8); + background: var(--color-accent-300); + border-bottom-color: var(--color-accent); } /* Toggle button active state */ .btn[aria-pressed="true"] { - background-color: rgba(255, 237, 74, 0.3); - border-color: rgba(255, 200, 0, 0.6); + background-color: var(--color-accent-300); + border-color: var(--color-accent-300); } .btn[aria-pressed="true"]:hover { - background-color: rgba(255, 237, 74, 0.5); + color: var(--color-text); + background-color: var(--color-accent-400); } @media (max-width: 768px) { @@ -142,7 +143,7 @@ } } -@media (min-width: 769px) and (max-width: 1200px) { +@media (min-width: 769px) and (max-width: 1400px) { .highlights-grid { column-count: 2; } diff --git a/assets/styles/04-pages/landing.css b/assets/styles/04-pages/landing.css index 9a8b4e4..651eeda 100644 --- a/assets/styles/04-pages/landing.css +++ b/assets/styles/04-pages/landing.css @@ -56,7 +56,7 @@ background: color-mix(in oklab, var(--color-primary) 18%, var(--color-bg)); } .ln-section--unfold{ - background: color-mix(in oklab, var(--color-accent-warm) 60%, var(--color-bg)); + background: color-mix(in oklab, var(--color-accent-strong) 60%, var(--color-bg)); } diff --git a/assets/styles/05-utilities/utilities.css b/assets/styles/05-utilities/utilities.css index f550f07..faaa5b0 100644 --- a/assets/styles/05-utilities/utilities.css +++ b/assets/styles/05-utilities/utilities.css @@ -67,4 +67,5 @@ details>summary{cursor:pointer} /* Text truncation with ellipsis */ +.line-clamp-3{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;text-overflow:ellipsis} .line-clamp-5{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:5;overflow:hidden;text-overflow:ellipsis} diff --git a/config/bundles.php b/config/bundles.php index 52b5f5f..b0d847d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -10,7 +10,7 @@ return [ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], - Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true, 'local' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true, 'dev' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], diff --git a/migrations/Version20251130103218.php b/migrations/Version20251130103218.php new file mode 100644 index 0000000..8ae0b3b --- /dev/null +++ b/migrations/Version20251130103218.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE article ALTER id ADD GENERATED BY DEFAULT AS IDENTITY'); + $this->addSql('DROP INDEX idx_event_kind'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE article ALTER id DROP IDENTITY'); + $this->addSql('CREATE INDEX idx_event_kind ON event (kind)'); + } +} diff --git a/src/Command/DebugHighlightsCommand.php b/src/Command/DebugHighlightsCommand.php new file mode 100644 index 0000000..a9bbe54 --- /dev/null +++ b/src/Command/DebugHighlightsCommand.php @@ -0,0 +1,120 @@ +addArgument('coordinate', InputArgument::OPTIONAL, 'Article coordinate (kind:pubkey:slug)') + ->setHelp('Debug highlights storage and retrieval. Run without arguments to see all stored coordinates.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $coordinate = $input->getArgument('coordinate'); + + if (!$coordinate) { + // List all stored coordinates + $coordinates = $this->highlightRepository->getAllArticleCoordinates(); + + $io->title('All Article Coordinates in Database'); + $io->writeln(sprintf('Found %d unique coordinates:', count($coordinates))); + $io->newLine(); + + foreach ($coordinates as $coord) { + $count = count($this->highlightRepository->findByArticleCoordinate($coord)); + $io->writeln(sprintf(' %s (%d highlights)', $coord, $count)); + } + + $io->newLine(); + $io->info('Run with a coordinate argument to see details: app:debug-highlights "30023:pubkey:slug"'); + + return Command::SUCCESS; + } + + // Debug specific coordinate + $io->title('Highlight Debug for: ' . $coordinate); + + // Check database + $io->section('Database Check'); + $dbHighlights = $this->highlightRepository->findByArticleCoordinate($coordinate); + $io->writeln(sprintf('Found %d highlights in database', count($dbHighlights))); + + if (count($dbHighlights) > 0) { + $io->table( + ['Event ID', 'Content Preview', 'Created At', 'Cached At'], + array_map(function($h) { + return [ + substr($h->getEventId(), 0, 16) . '...', + substr($h->getContent(), 0, 50) . '...', + date('Y-m-d H:i:s', $h->getCreatedAt()), + $h->getCachedAt()->format('Y-m-d H:i:s'), + ]; + }, array_slice($dbHighlights, 0, 5)) + ); + + if (count($dbHighlights) > 5) { + $io->writeln(sprintf('... and %d more', count($dbHighlights) - 5)); + } + } + + // Check cache status + $io->section('Cache Status'); + $needsRefresh = $this->highlightRepository->needsRefresh($coordinate, 24); + $lastCache = $this->highlightRepository->getLastCacheTime($coordinate); + + $io->writeln(sprintf('Needs refresh (24h): %s', $needsRefresh ? 'YES' : 'NO')); + $io->writeln(sprintf('Last cached: %s', $lastCache ? $lastCache->format('Y-m-d H:i:s') : 'Never')); + + // Try to fetch through service + $io->section('Service Fetch Test'); + $io->writeln('Fetching highlights through HighlightService...'); + + try { + $highlights = $this->highlightService->getHighlightsForArticle($coordinate); + $io->success(sprintf('Successfully fetched %d highlights', count($highlights))); + + if (count($highlights) > 0) { + $io->table( + ['Content Preview', 'Created At', 'Pubkey'], + array_map(function($h) { + return [ + substr($h['content'], 0, 50) . '...', + date('Y-m-d H:i:s', $h['created_at']), + substr($h['pubkey'], 0, 16) . '...', + ]; + }, array_slice($highlights, 0, 5)) + ); + } + } catch (\Exception $e) { + $io->error('Failed to fetch highlights: ' . $e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} + diff --git a/src/Command/ElevateUserCommand.php b/src/Command/ElevateUserCommand.php index 0b21de1..f9a525c 100644 --- a/src/Command/ElevateUserCommand.php +++ b/src/Command/ElevateUserCommand.php @@ -26,7 +26,7 @@ class ElevateUserCommand extends Command protected function configure(): void { $this - ->addArgument('arg1', InputArgument::REQUIRED, 'User npub') + ->addArgument('arg1', InputArgument::REQUIRED, 'User pubkey') ->addArgument('arg2', InputArgument::REQUIRED, 'Role to set'); } diff --git a/src/Command/ExportArticleListsCommand.php b/src/Command/ExportArticleListsCommand.php new file mode 100644 index 0000000..d6bcaad --- /dev/null +++ b/src/Command/ExportArticleListsCommand.php @@ -0,0 +1,55 @@ +entityManager->getRepository(Article::class); + $qb = $repo->createQueryBuilder('a') + ->where('a.kind = :kind') + ->andWhere('a.createdAt >= :since') + ->setParameter('kind', 30023) + ->setParameter('since', $since); + $articles = $qb->getQuery()->getResult(); + + $eventIds = []; + $coordinates = []; + foreach ($articles as $article) { + if (method_exists($article, 'getEventId')) { + $eventIds[] = $article->getEventId(); + } + // Build coordinate: "30023::" + if (method_exists($article, 'getPubkey') && method_exists($article, 'getSlug')) { + $coordinates[] = '30023:' . $article->getPubkey() . ':' . $article->getSlug(); + } + } + + // Output event IDs (first line) + $output->writeln(json_encode($eventIds)); + // Output coordinates (second line) + $output->writeln(json_encode($coordinates)); + + return Command::SUCCESS; + } +} + diff --git a/src/Command/NostrRelayPoolCleanupCommand.php b/src/Command/NostrRelayPoolCleanupCommand.php new file mode 100644 index 0000000..89b9282 --- /dev/null +++ b/src/Command/NostrRelayPoolCleanupCommand.php @@ -0,0 +1,49 @@ +addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of connections in seconds', 300) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $maxAge = (int) $input->getOption('max-age'); + + $io->info(sprintf('Cleaning up connections older than %d seconds...', $maxAge)); + + $cleaned = $this->relayPool->cleanupStaleConnections($maxAge); + + if ($cleaned > 0) { + $io->success(sprintf('Cleaned up %d stale connection(s).', $cleaned)); + } else { + $io->info('No stale connections found.'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/NostrRelayPoolStatsCommand.php b/src/Command/NostrRelayPoolStatsCommand.php new file mode 100644 index 0000000..ad060a6 --- /dev/null +++ b/src/Command/NostrRelayPoolStatsCommand.php @@ -0,0 +1,62 @@ +relayPool->getStats(); + + $io->title('Nostr Relay Pool Statistics'); + + $io->section('Overview'); + $io->table( + ['Metric', 'Value'], + [ + ['Active Connections', $stats['active_connections']], + ] + ); + + if (!empty($stats['relays'])) { + $io->section('Relay Details'); + $rows = []; + foreach ($stats['relays'] as $relay) { + $rows[] = [ + $relay['url'], + $relay['attempts'], + $relay['last_connected'] ? date('Y-m-d H:i:s', $relay['last_connected']) : 'Never', + $relay['age'] . 's', + ]; + } + $io->table( + ['Relay URL', 'Failed Attempts', 'Last Connected', 'Age'], + $rows + ); + } else { + $io->info('No active relay connections.'); + } + + return Command::SUCCESS; + } +} + diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 4971310..1822ebc 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -36,7 +36,7 @@ class ArticleController extends AbstractController * @throws \Exception */ #[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-zA-Z]+)$'])] - public function naddr(NostrClient $nostrClient, $naddr) + public function naddr(NostrClient $nostrClient, EntityManagerInterface $em, $naddr) { set_time_limit(120); // 2 minutes $decoded = new Bech32($naddr); @@ -57,18 +57,53 @@ class ArticleController extends AbstractController } $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); - if ($slug) { + // It's important to actually find the article + // Check if anything is in the database now + $repository = $em->getRepository(Article::class); + $article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $author]); + // If found, redirect to the article page + if ($slug && $article) { return $this->redirectToRoute('article-slug', ['slug' => $slug]); } - throw new \Exception('No article.'); + throw new \Exception('No article found.'); } - /** - * @throws InvalidArgumentException|CommonMarkException - */ + #[Route('/article/d/{slug}', name: 'article-slug', requirements: ['slug' => '.+'])] - public function article( + public function disambiguation($slug, EntityManagerInterface $entityManager): Response + { + $slug = urldecode($slug); + $repository = $entityManager->getRepository(Article::class); + $articles = $repository->findBy(['slug' => $slug]); + $count = count($articles); + if ($count === 0) { + throw $this->createNotFoundException('No articles found for this slug'); + } + if ($count === 1) { + $key = new Key(); + $npub = $key->convertPublicKeyToBech32($articles[0]->getPubkey()); + return $this->redirectToRoute('author-article-slug', ['npub' => $npub, 'slug' => $slug]); + } + $authors = []; + $key = new Key(); + foreach ($articles as $article) { + $authors[] = [ + 'npub' => $key->convertPublicKeyToBech32($article->getPubkey()), + 'pubkey' => $article->getPubkey(), + 'createdAt' => $article->getCreatedAt(), + ]; + } + return $this->render('pages/article_disambiguation.html.twig', [ + 'slug' => $slug, + 'authors' => $authors, + 'articles' => $articles + ]); + } + + #[Route('/p/{npub}/article/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])] + public function authorArticle( + $npub, $slug, EntityManagerInterface $entityManager, RedisCacheService $redisCacheService, @@ -77,44 +112,23 @@ class ArticleController extends AbstractController HighlightService $highlightService ): Response { - - set_time_limit(300); // 5 minutes + set_time_limit(300); 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) { + $key = new Key(); + $pubkey = $key->convertToHex($npub); + $repository = $entityManager->getRepository(Article::class); + $article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $pubkey]); + if (!$article) { 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) { @@ -125,22 +139,12 @@ class ArticleController extends AbstractController $canEdit = false; } } - - $canonical = $this->generateUrl('article-slug', ['slug' => $article->getSlug()], 0); - - // Fetch highlights using the caching service + $canonical = $this->generateUrl('author-article-slug', ['npub' => $npub, 'slug' => $article->getSlug()], 0); $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()); - } - + } catch (\Exception $e) {} return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index a43c5c2..d0eb2d7 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -161,13 +161,13 @@ class AuthorController extends AbstractController * @throws Exception|InvalidArgumentException */ #[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])] - public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response + public function media($npub, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response { - - $author = $redisCacheService->getMetadata($keyUtil->npubToHex($npub)); + $pubkey = $keyUtil->npubToHex($npub); + $author = $redisCacheService->getMetadata($pubkey); // Use paginated cached media events - fetches 200 from relays, serves first 24 - $paginatedData = $redisCacheService->getMediaEventsPaginated($keyUtil->npubToHex($npub), 1, 24); + $paginatedData = $redisCacheService->getMediaEventsPaginated($pubkey, 1, 24); $mediaEvents = $paginatedData['events']; // Encode event IDs as note1... for each event @@ -179,6 +179,7 @@ class AuthorController extends AbstractController return $this->render('profile/author-media.html.twig', [ 'author' => $author, 'npub' => $npub, + 'pubkey' => $pubkey, 'pictureEvents' => $mediaEvents, 'hasMore' => $paginatedData['hasMore'], 'total' => $paginatedData['total'], @@ -229,6 +230,7 @@ class AuthorController extends AbstractController * @throws InvalidArgumentException */ #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] + #[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])] public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder, MessageBusInterface $messageBus): Response { diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index a290ef7..e011157 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -408,4 +408,120 @@ class DefaultController extends AbstractController return new Response('
Unable to load OG preview for ' . htmlspecialchars($url) . '
', 200); } } + + /** + * Nostr Preview endpoint for Nostr identifiers (naddr, nevent, note, npub, nprofile) + */ + #[Route('/preview/', name: 'nostr_preview', methods: ['POST'])] + public function nostrPreview(RequestStack $requestStack, EntityManagerInterface $entityManager, LoggerInterface $logger): Response + { + $request = $requestStack->getCurrentRequest(); + $data = json_decode($request->getContent(), true); + + $identifier = $data['identifier'] ?? null; + $type = $data['type'] ?? null; + $decoded = $data['decoded'] ?? null; + + if (!$identifier || !$type) { + return new Response('
Invalid preview request.
', 400); + } + + // If decoded is a JSON string, decode it to array + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + // Ensure decoded is an array + if (!is_array($decoded)) { + $logger->error('Decoded data is not an array', [ + 'decoded' => $decoded, + 'type' => gettype($decoded) + ]); + return new Response('
Invalid preview data format.
', 400); + } + + try { + // Handle different Nostr identifier types + switch ($type) { + case 'naddr': + return $this->handleNaddrPreview($decoded, $entityManager, $logger); + case 'nevent': + case 'note': + return $this->handleEventPreview($decoded, $entityManager, $logger); + case 'npub': + case 'nprofile': + return $this->handleProfilePreview($decoded, $entityManager, $logger); + default: + return new Response('
Unsupported preview type: ' . htmlspecialchars($type) . '
', 200); + } + } catch (\Exception $e) { + $logger->error('Error generating Nostr preview', [ + 'identifier' => $identifier, + 'type' => $type, + 'error' => $e->getMessage() + ]); + return new Response('
Unable to load preview.
', 200); + } + } + + private function handleNaddrPreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response + { + $kind = $decoded['kind'] ?? null; + $pubkey = $decoded['pubkey'] ?? null; + $identifier = $decoded['identifier'] ?? null; + + if ($kind === KindsEnum::LONGFORM->value) { + // Try to find article in database + $repository = $entityManager->getRepository(Article::class); + $article = $repository->findOneBy(['slug' => $identifier, 'pubkey' => $pubkey]); + + if ($article) { + $key = new Key(); + $npub = $key->convertPublicKeyToBech32($article->getPubkey()); + + return $this->render('components/Molecules/ArticlePreview.html.twig', [ + 'article' => $article, + 'npub' => $npub + ]); + } + + // Article not in database yet - show a link to fetch it + // We need to construct the naddr from the decoded data + try { + $relays = $decoded['relays'] ?? []; + $naddr = \nostriphant\NIP19\Bech32::naddr( + kind: (int)$kind, + pubkey: $pubkey, + identifier: $identifier, + relays: $relays + ); + + return new Response( + '
+ Article Preview
+ This article hasn\'t been fetched yet. + Click here to view it +
', + 200 + ); + } catch (\Exception $e) { + $logger->error('Failed to generate naddr for preview', ['error' => $e->getMessage()]); + return new Response('
Unable to generate article link.
', 200); + } + } + + return new Response('
Preview for kind ' . $kind . ' not yet supported.
', 200); + } + + private function handleEventPreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response + { + // For now, just show a basic preview + return new Response('
Event preview coming soon.
', 200); + } + + private function handleProfilePreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response + { + // For now, just show a basic preview + return new Response('
Profile preview coming soon.
', 200); + } } diff --git a/src/Controller/HighlightsController.php b/src/Controller/HighlightsController.php index 012ab09..e717493 100644 --- a/src/Controller/HighlightsController.php +++ b/src/Controller/HighlightsController.php @@ -23,6 +23,7 @@ class HighlightsController extends AbstractController private readonly NostrClient $nostrClient, private readonly HighlightService $highlightService, private readonly LoggerInterface $logger, + private readonly \App\Service\NostrLinkParser $nostrLinkParser, ) {} #[Route('/highlights', name: 'highlights')] @@ -31,7 +32,7 @@ class HighlightsController extends AbstractController try { // Cache key for highlights $cacheKey = 'global_article_highlights'; - // $cache->delete($cacheKey); + $cache->delete($cacheKey); // Get highlights from cache or fetch fresh $highlights = $cache->get($cacheKey, function (ItemInterface $item) { $item->expiresAfter(self::CACHE_TTL); @@ -41,7 +42,7 @@ class HighlightsController extends AbstractController $events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS); // Save raw events to database first (group by article) - $this->saveHighlightsToDatabase($events); + //$this->saveHighlightsToDatabase($events); // Process and enrich the highlights for display return $this->processHighlights($events); @@ -183,6 +184,12 @@ class HighlightsController extends AbstractController if ($highlight['article_ref'] && str_starts_with($highlight['article_ref'], '30023:')) { // Generate naddr from the coordinate $highlight['naddr'] = $this->generateNaddr($highlight['article_ref'], $relayHints); + + // Parse naddr to create preview data for NostrPreview component + if ($highlight['naddr']) { + $highlight['preview'] = $this->createPreviewData($highlight['naddr']); + } + $processed[] = $highlight; } } @@ -235,5 +242,28 @@ class HighlightsController extends AbstractController return null; } } + + /** + * Create preview data structure for NostrPreview component + */ + private function createPreviewData(string $naddr): ?array + { + try { + // Use NostrLinkParser to parse the naddr identifier + $links = $this->nostrLinkParser->parseLinks("nostr:$naddr"); + + if (!empty($links)) { + return $links[0]; + } + + return null; + } catch (\Exception $e) { + $this->logger->debug('Failed to create preview data', [ + 'naddr' => $naddr, + 'error' => $e->getMessage() + ]); + return null; + } + } } diff --git a/src/Controller/RelayAdminController.php b/src/Controller/RelayAdminController.php index 98bb178..4d77934 100644 --- a/src/Controller/RelayAdminController.php +++ b/src/Controller/RelayAdminController.php @@ -25,7 +25,7 @@ class RelayAdminController extends AbstractController $config = $this->relayAdminService->getConfiguration(); $containerStatus = $this->relayAdminService->getContainerStatus(); $connectivity = $this->relayAdminService->testConnectivity(); - $recentEvents = $this->relayAdminService->getRecentEvents(5); + $recentEvents = $this->relayAdminService->getRecentEvents(50); return $this->render('admin/relay/index.html.twig', [ 'stats' => $stats, diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 2c258a9..6265dab 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -102,7 +102,7 @@ class NostrClient $authorRelays = []; } if (empty($authorRelays)) { - return [self::REPUTABLE_RELAYS[0]]; // Default to theforest if no author relays + return self::REPUTABLE_RELAYS; // Default to theforest if no author relays } $reputableAuthorRelays = []; @@ -205,17 +205,21 @@ class NostrClient } $requestMessage = new RequestMessage($subscriptionId, [$filter]); - $request = new Request($this->defaultRelaySet, $requestMessage); + // Create relay set from all reputable relays on record + $relaySet = $this->createRelaySet(self::REPUTABLE_RELAYS); - $response = $request->send(); - // response is an n-dimensional array, where n is the number of relays in the set - // check that response has events in the results - foreach ($response as $relayRes) { - $filtered = array_filter($relayRes, function ($item) { - return $item->type === 'EVENT'; - }); - if (count($filtered) > 0) { - $this->saveLongFormContent($filtered); + $request = new Request($relaySet, $requestMessage); + + // Process the response + $events = $this->processResponse($request->send(), function($event) { + return $event; + }); + + if (!empty($events)) { + foreach ($events as $event) { + $article = $this->articleFactory->createFromLongFormContentEvent($event); + // check if event with same eventId already in DB + $this->saveEachArticleToTheDatabase($article); } } } @@ -437,6 +441,7 @@ class NostrClient { $cacheKey = 'npub_relays_' . $npub; try { + // $this->npubCache->deleteItem($cacheKey); $cachedItem = $this->npubCache->getItem($cacheKey); if ($cachedItem->isHit()) { $this->logger->debug('Using cached relays for npub', ['npub' => $npub]); @@ -448,7 +453,7 @@ class NostrClient // Get relays $request = $this->createNostrRequest( - kinds: [KindsEnum::RELAY_LIST], + kinds: [KindsEnum::RELAY_LIST->value], filters: ['authors' => [$npub]], relaySet: $this->defaultRelaySet ); @@ -967,7 +972,7 @@ class NostrClient */ public function getArticleHighlights(int $limit = 50): array { - $this->logger->info('Fetching article highlights from default relays'); + $this->logger->info('Fetching article highlights from default relay'); // Use relay pool to send request $subscription = new Subscription(); @@ -979,10 +984,12 @@ class NostrClient $requestMessage = new RequestMessage($subscriptionId, [$filter]); - // Get default relay URLs - $relayUrls = $this->relayPool->getDefaultRelays(); + // Use only the configured default relay + $relayUrls = $this->nostrDefaultRelay + ? [$this->nostrDefaultRelay] + : [($this->relayPool->getDefaultRelays()[0] ?? null)]; + $relayUrls = array_filter($relayUrls); // Remove nulls if fallback fails - // Use the relay pool to send the request $responses = $this->relayPool->sendToRelays( $relayUrls, fn() => $requestMessage, @@ -1032,7 +1039,7 @@ class NostrClient $filter->setKinds([9802]); // NIP-84 highlights $filter->setLimit($limit); // Add tag filter for the specific article coordinate - $filter->setTags(['a' => [$articleCoordinate]]); + $filter->setTags(['#a' => [$articleCoordinate]]); $requestMessage = new RequestMessage($subscriptionId, [$filter]); @@ -1078,7 +1085,6 @@ class NostrClient } } } - $this->logger->info('Relay set for request', ['relays' => $relaySet ? $relaySet->getRelays() : 'default']); $requestMessage = new RequestMessage($subscription->getId(), [$filter]); diff --git a/src/Service/NostrRelayPool.php b/src/Service/NostrRelayPool.php index 7d0729a..900e48e 100644 --- a/src/Service/NostrRelayPool.php +++ b/src/Service/NostrRelayPool.php @@ -5,6 +5,7 @@ namespace App\Service; use Psr\Log\LoggerInterface; use swentel\nostr\Relay\Relay; use swentel\nostr\RelayResponse\RelayResponse; +use WebSocket\Exception\Exception; /** * Manages persistent WebSocket connections to Nostr relays @@ -21,6 +22,13 @@ class NostrRelayPool /** @var array Track last connection time */ private array $lastConnected = []; + /** @var array */ + private array $defaultRelays = [ + 'wss://theforest.nostr1.com', + 'wss://nostr.land', + 'wss://relay.primal.net', + ]; + private const MAX_RETRIES = 3; private const RETRY_DELAY = 5; // seconds private const CONNECTION_TIMEOUT = 30; // seconds @@ -28,12 +36,15 @@ class NostrRelayPool public function __construct( private readonly LoggerInterface $logger, - private readonly array $defaultRelays = [ - 'wss://theforest.nostr1.com', - 'wss://nostr.land', - 'wss://relay.primal.net', - ] - ) {} + private readonly string $nostrDefaultRelay, + array $defaultRelays = [] + ) { + $relayList = $defaultRelays ?: $this->defaultRelays; + if ($this->nostrDefaultRelay && !in_array($this->nostrDefaultRelay, $relayList, true)) { + array_unshift($relayList, $this->nostrDefaultRelay); + } + $this->defaultRelays = $relayList; + } /** * Normalize relay URL to ensure consistency @@ -77,7 +88,7 @@ class NostrRelayPool } /** - * Get multiple relay connections + * Get multiple relay connections, prioritizing default relay * * @param array $relayUrls * @return array @@ -85,7 +96,27 @@ class NostrRelayPool public function getRelays(array $relayUrls): array { $relays = []; - foreach ($relayUrls as $url) { + $defaultRelay = $this->defaultRelays[0] ?? null; + $relayUrlsNormalized = array_map([$this, 'normalizeRelayUrl'], $relayUrls); + $defaultRelayNormalized = $defaultRelay ? $this->normalizeRelayUrl($defaultRelay) : null; + + // Try default relay first if present in requested URLs + if ($defaultRelayNormalized && in_array($defaultRelayNormalized, $relayUrlsNormalized, true)) { + try { + $relays[] = $this->getRelay($defaultRelayNormalized); + } catch (\Throwable $e) { + $this->logger->warning('Default relay unavailable, falling back to others', [ + 'relay' => $defaultRelayNormalized, + 'error' => $e->getMessage() + ]); + } + } + + // Add other relays except the default + foreach ($relayUrlsNormalized as $url) { + if ($url === $defaultRelayNormalized) { + continue; + } try { $relays[] = $this->getRelay($url); } catch (\Throwable $e) { @@ -99,7 +130,7 @@ class NostrRelayPool } /** - * Send a request to multiple relays and collect responses + * Send a request to multiple relays and collect responses, prioritizing default relay * * @param array $relayUrls Array of relay URLs to query * @param callable $messageBuilder Function that builds the message to send @@ -198,14 +229,17 @@ class NostrRelayPool // Update last connected time on successful send $this->lastConnected[$relay->getUrl()] = time(); - } catch (\WebSocket\TimeoutException $e) { - // Timeout is normal - relay has sent all events - $this->logger->debug('Relay timeout (normal - all events received)', [ - 'relay' => $relay->getUrl() - ]); - $responses[$relay->getUrl()] = $relayResponses; - $this->lastConnected[$relay->getUrl()] = time(); - + } catch (Exception $e) { + // If this is a timeout, treat as normal; otherwise, rethrow or handle + if (stripos($e->getMessage(), 'timeout') !== false) { + $this->logger->debug('Relay timeout (normal - all events received)', [ + 'relay' => $relay->getUrl() + ]); + $responses[$relay->getUrl()] = $relayResponses; + $this->lastConnected[$relay->getUrl()] = time(); + } else { + throw $e; + } } catch (\Throwable $e) { $this->logger->error('Error sending to relay', [ 'relay' => $relay->getUrl(), @@ -307,4 +341,3 @@ class NostrRelayPool return $this->defaultRelays; } } - diff --git a/src/Service/RelayAdminService.php b/src/Service/RelayAdminService.php index a79e0c4..359219d 100644 --- a/src/Service/RelayAdminService.php +++ b/src/Service/RelayAdminService.php @@ -80,7 +80,7 @@ class RelayAdminService // Create filter for recent events (kind 30023 - articles) $filter = new Filter(); - $filter->setKinds([30023, 1, 7, 0]); // Articles, notes, reactions, profiles + $filter->setKinds([30023, 9802]); // Articles, highlights $filter->setLimit($limit); // Create and send request @@ -153,11 +153,9 @@ class RelayAdminService public function getContainerStatus(): array { $strfryStatus = $this->checkServiceHealth('strfry', 7777); - $ingestStatus = ['status' => 'unknown', 'health' => 'Cannot check from inside container']; return [ 'strfry' => $strfryStatus, - 'ingest' => $ingestStatus, ]; } diff --git a/templates/admin/relay/index.html.twig b/templates/admin/relay/index.html.twig index 4f254c5..8edb558 100644 --- a/templates/admin/relay/index.html.twig +++ b/templates/admin/relay/index.html.twig @@ -2,139 +2,13 @@ {% block title %}Relay Administration{% endblock %} -{% block layout %} -
- - -
-

🛰️ Relay Administration

-

Monitor and manage your local Nostr relay

-
+
{# Status Overview #}
@@ -153,17 +27,6 @@
-
- Ingest Service - - {% if container_status.ingest.status == 'running' %} - Running - {% else %} - {{ container_status.ingest.status|default('Not Running') }} - {% endif %} - -
-
Port 7777 diff --git a/templates/components/Molecules/ArticlePreview.html.twig b/templates/components/Molecules/ArticlePreview.html.twig new file mode 100644 index 0000000..9248cfb --- /dev/null +++ b/templates/components/Molecules/ArticlePreview.html.twig @@ -0,0 +1,34 @@ +
+
+ {% if article.image %} +
+ {{ article.title }} +
+ {% endif %} +
+
+ +
+ + +
+ +
+
+ + {{ article.title }} + +
+ + {% if article.summary %} +

{{ article.summary }}

+ {% endif %} +
+
+
+ diff --git a/templates/components/Molecules/ZapButton.html.twig b/templates/components/Molecules/ZapButton.html.twig index 023a3bf..04e2434 100644 --- a/templates/components/Molecules/ZapButton.html.twig +++ b/templates/components/Molecules/ZapButton.html.twig @@ -7,7 +7,7 @@ data-live-action-param="openDialog" title="Send a zap" > - ⚡ Zap + Zap {# Modal Dialog #} @@ -17,7 +17,7 @@
{# Header #}
-
⚡ Send Zap
+
Send Zap
{% endif %} diff --git a/templates/pages/article_disambiguation.html.twig b/templates/pages/article_disambiguation.html.twig new file mode 100644 index 0000000..461ac2e --- /dev/null +++ b/templates/pages/article_disambiguation.html.twig @@ -0,0 +1,24 @@ +{% extends 'layout.html.twig' %} + +{% block body %} +

Disambiguation: Multiple Articles for "{{ slug }}"

+

There are multiple articles with the slug {{ slug }}. Please select the author:

+
    + {% for author, article in authors|zip(articles) %} +
  • + + Author: {{ author.npub }} + + Published: {{ article.createdAt|date('Y-m-d H:i') }} + {% if article.title %} - {{ article.title }}{% endif %} +
  • + {% endfor %} +
+{% endblock %} + +{% block aside %} +
+

What is this?

+

This page lists all articles with the same slug. Click an author to view their version.

+
+{% endblock %} diff --git a/templates/pages/highlights.html.twig b/templates/pages/highlights.html.twig index db8ca1d..3279aeb 100644 --- a/templates/pages/highlights.html.twig +++ b/templates/pages/highlights.html.twig @@ -57,8 +57,13 @@