Browse Source

Better article management

imwald
Nuša Pukšič 8 months ago
parent
commit
5446fed640
  1. 2
      assets/styles/layout.css
  2. 2
      src/Command/ElevateUserCommand.php
  3. 19
      src/Controller/AuthorController.php
  4. 51
      src/Controller/DefaultController.php
  5. 306
      src/Service/NostrClient.php
  6. 5
      templates/components/UserMenu.html.twig
  7. 11
      templates/pages/article.html.twig

2
assets/styles/layout.css

@ -24,6 +24,7 @@ @@ -24,6 +24,7 @@
nav {
width: 21vw;
min-width: 150px;
max-width: 280px;
flex-shrink: 0;
padding: 1em;
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
@ -107,6 +108,7 @@ main { @@ -107,6 +108,7 @@ main {
top: 150px;
width: 21vw;
min-width: 150px;
max-width: 280px;
}
.user-nav {

2
src/Command/ElevateUserCommand.php

@ -38,6 +38,7 @@ class ElevateUserCommand extends Command @@ -38,6 +38,7 @@ class ElevateUserCommand extends Command
return Command::INVALID;
}
/** @var User|null $user */
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
if (!$user) {
return Command::FAILURE;
@ -46,6 +47,7 @@ class ElevateUserCommand extends Command @@ -46,6 +47,7 @@ class ElevateUserCommand extends Command
$user->addRole($role);
$this->entityManager->persist($user);
$this->entityManager->flush();
$output->writeln(sprintf('User %s elevated to role %s', $npub, $role));
return Command::SUCCESS;
}

19
src/Controller/AuthorController.php

@ -4,6 +4,7 @@ declare(strict_types=1); @@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Service\NostrClient;
use App\Service\RedisCacheService;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
@ -20,18 +21,21 @@ class AuthorController extends AbstractController @@ -20,18 +21,21 @@ class AuthorController extends AbstractController
* @throws InvalidArgumentException
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder): Response
public function index($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, FinderInterface $finder): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($npub);
$relays = $redisCacheService->getRelays($npub);
// Look for articles in index, assume indexing is done regularly
// TODO give users an option to reindex
// Retrieve long-form content for the author
try {
$list = $nostrClient->getLongFormContentForPubkey($npub);
} catch (\Exception $e) {
$list = [];
}
// Also look for articles in the Elastica index
$query = new Terms('pubkey', [$pubkey]);
$list = $finder->find($query, 25);
$list = array_merge($list, $finder->find($query, 25));
// deduplicate by slugs
$articles = [];
@ -44,8 +48,7 @@ class AuthorController extends AbstractController @@ -44,8 +48,7 @@ class AuthorController extends AbstractController
return $this->render('Pages/author.html.twig', [
'author' => $author,
'npub' => $npub,
'articles' => $articles,
'relays' => $relays
'articles' => $articles
]);
}

51
src/Controller/DefaultController.php

@ -11,6 +11,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -11,6 +11,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use App\Service\NostrClient;
use App\Factory\ArticleFactory;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController
{
@ -47,7 +50,10 @@ class DefaultController extends AbstractController @@ -47,7 +50,10 @@ class DefaultController extends AbstractController
*/
#[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, CacheInterface $redisCache,
FinderInterface $finder): Response
FinderInterface $finder,
NostrClient $nostrClient,
ArticleFactory $articleFactory,
LoggerInterface $logger): Response
{
$catIndex = $redisCache->get('magazine-' . $slug, function (){
throw new \Exception('Not found');
@ -55,6 +61,7 @@ class DefaultController extends AbstractController @@ -55,6 +61,7 @@ class DefaultController extends AbstractController
$list = [];
$slugs = [];
$coordinates = []; // Store full coordinates (kind:author:slug)
$category = [];
foreach ($catIndex->getTags() as $tag) {
@ -68,12 +75,12 @@ class DefaultController extends AbstractController @@ -68,12 +75,12 @@ class DefaultController extends AbstractController
$parts = explode(':', $tag[1]);
if (count($parts) === 3) {
$slugs[] = $parts[2];
$coordinates[] = $tag[1]; // Store the full coordinate
}
}
}
if (!empty($slugs)) {
$query = new Terms('slug', array_values($slugs));
$articles = $finder->find($query);
@ -88,16 +95,44 @@ class DefaultController extends AbstractController @@ -88,16 +95,44 @@ class DefaultController extends AbstractController
}
}
if (!empty($res)) {
foreach ($res as $result) {
if (!isset($slugMap[$result->getSlug()])) {
$slugMap[$result->getSlug()] = $result;
}
// Find missing articles based on coordinates
$missingCoordinates = [];
$missingIndexes = [];
for ($i = 0; $i < count($slugs); $i++) {
$slug = $slugs[$i];
if (!isset($slugMap[$slug])) {
$missingCoordinates[] = $coordinates[$i];
$missingIndexes[$coordinates[$i]] = $i; // Track original position
}
}
// If we have missing articles, fetch them from nostr
if (!empty($missingCoordinates)) {
$logger->info('Fetching missing articles', [
'missing' => $missingCoordinates
]);
try {
$nostrArticles = $nostrClient->getArticlesByCoordinates($missingCoordinates);
foreach ($nostrArticles as $coordinate => $event) {
$parts = explode(':', $coordinate);
if (count($parts) === 3) {
$article = $articleFactory->createFromLongFormContentEvent($event);
// Add to the slugMap
$slugMap[$article->getSlug()] = $article;
}
}
} catch (\Exception $e) {
$logger->error('Error fetching missing articles', [
'error' => $e->getMessage()
]);
}
}
// Reorder by the original $slugs
// Reorder by the original $slugs to maintain order
$results = [];
foreach ($slugs as $slug) {
if (isset($slugMap[$slug])) {

306
src/Service/NostrClient.php

@ -3,25 +3,20 @@ @@ -3,25 +3,20 @@
namespace App\Service;
use App\Entity\Article;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Factory\ArticleFactory;
use App\Repository\UserEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\SerializerInterface;
class NostrClient
{
@ -48,6 +43,7 @@ class NostrClient @@ -48,6 +43,7 @@ class NostrClient
{
$this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://thecitadel.nostr1.com')); // public aggregator relay
}
/**
@ -55,7 +51,7 @@ class NostrClient @@ -55,7 +51,7 @@ class NostrClient
*/
private function createRelaySet(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$relaySet = $this->defaultRelaySet;
foreach ($relayUrls as $relayUrl) {
$relaySet->addRelay(new Relay($relayUrl));
}
@ -215,39 +211,45 @@ class NostrClient @@ -215,39 +211,45 @@ class NostrClient
}
}
/**
* @throws \Exception
*/
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([$kind]);
$filter->setAuthors([$author]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// First try with theforest relay and any relays in $relayList
// Add theforest relay to the list, if not already present
if (!in_array('wss://theforest.nostr1.com', $relayList)) {
$relayList[] = 'wss://theforest.nostr1.com';
if (!empty($relayList)) {
// Filter out relays that exist in the REPUTABLE_RELAYS list
$relayList = array_filter($relayList, function ($relay) {
// in array REPUTABLE_RELAYS
return in_array($relay, self::REPUTABLE_RELAYS) && str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
$relaySet = $this->createRelaySet($relayList);
}
$forestRelaySet = $this->createRelaySet($relayList);
$response = null;
$hasEvents = false;
try {
$request = new Request($forestRelaySet, $requestMessage);
$response = $request->send();
// Create request using the helper method for forest relay set
$request = $this->createNostrRequest(
kinds: [$kind],
filters: [
'authors' => [$author],
'tag' => ['#d', [$slug]]
],
relaySet: $relaySet ?? $this->defaultRelaySet
);
// Check if we got any events
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
// Process the response
$events = $this->processResponse($request->send(), function($event) {
return $event;
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
if (!empty($events)) {
$this->saveLongFormContent(array_map(function($event) {
$wrapper = new \stdClass();
$wrapper->type = 'EVENT';
$wrapper->event = $event;
return $wrapper;
}, $events));
$hasEvents = true;
break;
}
}
// If no events found in theforest, try author's reputable relays
@ -255,21 +257,32 @@ class NostrClient @@ -255,21 +257,32 @@ class NostrClient
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
$this->logger->info('No results from theforest, trying author relays', [
$this->logger->info('No results, trying author relays', [
'relays' => $topAuthorRelays
]);
$request = new Request($authorRelaySet, $requestMessage);
$response = $request->send();
// Create request using the helper method for author relay set
$request = $this->createNostrRequest(
kinds: [$kind],
filters: [
'authors' => [$author],
'tag' => ['#d', [$slug]]
],
relaySet: $authorRelaySet
);
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
// Process the response
$events = $this->processResponse($request->send(), function($event) {
return $event;
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
break;
}
if (!empty($events)) {
$this->saveLongFormContent(array_map(function($event) {
$wrapper = new \stdClass();
$wrapper->type = 'EVENT';
$wrapper->event = $event;
return $wrapper;
}, $events));
}
}
} catch (\Exception $e) {
@ -277,17 +290,7 @@ class NostrClient @@ -277,17 +290,7 @@ class NostrClient
$this->logger->error('Error querying relays, falling back to defaults', [
'error' => $e->getMessage()
]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
}
throw new \Exception('Error querying relays', 0, $e);
}
}
@ -296,17 +299,7 @@ class NostrClient @@ -296,17 +299,7 @@ class NostrClient
foreach ($filtered as $wrapper) {
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event);
// check if event with same eventId already in DB
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
if (!$saved) {
try {
$this->logger->info('Saving article', ['article' => $article]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
$this->saveEachArticleToTheDatabase($article);
}
}
@ -315,13 +308,10 @@ class NostrClient @@ -315,13 +308,10 @@ class NostrClient
*/
public function getNpubRelays($npub): array
{
// Convert npub to hex
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
// Get relays
$request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$pubkey]],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
);
$response = $this->processResponse($request->send(), function($received) {
@ -387,43 +377,32 @@ class NostrClient @@ -387,43 +377,32 @@ class NostrClient
/**
* @throws \Exception
*/
public function getLongFormContentForPubkey(string $pubkey): array
public function getLongFormContentForPubkey(string $ident): array
{
$articles = [];
// Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident);
// Create a RelaySet from the author's relays
$relaySet = $this->defaultRelaySet;
if (!empty($authorRelays)) {
$relaySet = $this->createRelaySet($authorRelays);
}
// look for last months long-form notes
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setLimit(10);
$filter->setAuthors([$pubkey]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
// Create request using the helper method
$request = $this->createNostrRequest(
kinds: [KindsEnum::LONGFORM],
filters: [
'authors' => [$ident],
'limit' => 10
],
relaySet: $relaySet
);
// response is an array of arrays
foreach ($response as $value) {
foreach ($value as $item) {
if (is_array($item)) continue;
switch ($item->type) {
case 'EVENT':
$article = $this->articleFactory->createFromLongFormContentEvent($item->event);
$articles[] = $article;
break;
case 'AUTH':
// throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
// throw new \Exception('An error occurred');
default:
// nothing to do here
}
}
}
return $articles;
// Process the response using the helper method
return $this->processResponse($request->send(), function($event) {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
// Save each article to the database
$this->saveEachArticleToTheDatabase($article);
});
}
public function getArticles(array $slugs): array
@ -495,6 +474,108 @@ class NostrClient @@ -495,6 +474,108 @@ class NostrClient
return $articles;
}
/**
* Fetch articles by coordinates (kind:author:slug)
* Returns a map of coordinate => event for successful fetches
*
* @param array $coordinates Array of coordinates in format kind:author:slug
* @return array Map of coordinate => event
* @throws \Exception
*/
public function getArticlesByCoordinates(array $coordinates): array
{
$articlesMap = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate);
if (count($parts) !== 3) {
$this->logger->warning('Invalid coordinate format', ['coordinate' => $coordinate]);
continue;
}
$kind = (int)$parts[0];
$pubkey = $parts[1];
$slug = $parts[2];
// Try to get relays associated with the author first
$relayList = [];
try {
// Get relays where the author publishes
$authorRelays = $this->getTopReputableRelaysForAuthor($pubkey);
if (!empty($authorRelays)) {
$relayList = $authorRelays;
}
} catch (\Exception $e) {
$this->logger->warning('Failed to get author relays', [
'pubkey' => $pubkey,
'error' => $e->getMessage()
]);
// Continue with default relays
}
// If no author relays found, add default relay
if (empty($relayList)) {
$relayList = [self::REPUTABLE_RELAYS[0]];
}
// Ensure we use a RelaySet
$relaySet = $this->createRelaySet($relayList);
// Create subscription and filter
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([$kind]);
$filter->setAuthors([$pubkey]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
try {
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
$found = false;
// Check responses from each relay
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
$found = true;
break 2; // Found what we need, exit both loops
}
}
}
// If still not found, try with default relay set as fallback
if (!$found) {
$this->logger->info('Article not found in author relays, trying default relays', [
'coordinate' => $coordinate
]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
break 2;
}
}
}
}
} catch (\Exception $e) {
$this->logger->error('Error fetching article', [
'coordinate' => $coordinate,
'error' => $e->getMessage()
]);
}
}
return $articlesMap;
}
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request
{
$subscription = new Subscription();
@ -504,9 +585,15 @@ class NostrClient @@ -504,9 +585,15 @@ class NostrClient
foreach ($filters as $key => $value) {
$method = 'set' . ucfirst($key);
if (method_exists($filter, $method)) {
// If it's tags, we need to handle it differently
if ($key === 'tag') {
$filter->setTag($value[0], $value[1]);
} else {
// Call the method with the value
$filter->$method($value);
}
}
}
$requestMessage = new RequestMessage($subscription->getId(), [$filter]);
return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage);
@ -516,10 +603,12 @@ class NostrClient @@ -516,10 +603,12 @@ class NostrClient
{
$results = [];
foreach ($response as $relayRes) {
$this->logger->warning('Response from relay', $response);
foreach ($relayRes as $item) {
try {
switch ($item->type) {
case 'EVENT':
$this->logger->info('Processing event', ['event' => $item->event]);
$result = $eventHandler($item->event);
if ($result !== null) {
$results[] = $result;
@ -543,4 +632,23 @@ class NostrClient @@ -543,4 +632,23 @@ class NostrClient
}
return $results;
}
/**
* @param Article $article
* @return void
*/
public function saveEachArticleToTheDatabase(Article $article): void
{
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
if (!$saved) {
try {
$this->logger->info('Saving article', ['article' => $article]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
}
}

5
templates/components/UserMenu.html.twig

@ -2,8 +2,8 @@ @@ -2,8 +2,8 @@
{% if app.user %}
<div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
{% if is_granted('ROLE_ADMIN') %}<span class="badge">Admin</span>{% endif %}
</div>
{# <p>Hello, {{ app.user.name }}</p>#}
{% if is_granted('ROLE_ADMIN') %}
{# <ul>#}
{# <li>#}
@ -12,9 +12,6 @@ @@ -12,9 +12,6 @@
{# </ul>#}
{% endif %}
<ul class="user-nav">
<li>
<a href="{{ path('author-profile', {npub: app.user.npub }) }}">Profile</a>
</li>
{# <li>#}
{# <a href="{{ path('editor-create') }}">Write an article</a>#}
{# </li>#}

11
templates/pages/article.html.twig

@ -12,6 +12,14 @@ @@ -12,6 +12,14 @@
{% endblock %}
{% block body %}
{% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
Copy coordinates
</button>
{% endif %}
<div class="card">
<div class="card-header">
<h1 class="card-title">{{ article.title }}</h1>
@ -66,9 +74,6 @@ @@ -66,9 +74,6 @@
{% endblock %}
{% block aside %}
{% if is_granted('ROLE_ADMIN') %}
<p>30032:{{ article.pubkey }}:{{ article.slug }}</p>
{% endif %}
{# <h1>Suggestions</h1>#}
{# <twig:Organisms:CardList :list="suggestions" />#}
{% endblock %}

Loading…
Cancel
Save