Browse Source

Update security, refactor queries for search

imwald
Nuša Pukšič 8 months ago
parent
commit
31816dfe66
  1. 1
      assets/app.js
  2. 11
      assets/styles/layout.css
  3. 15
      assets/styles/notice.css
  4. 30
      config/packages/fos_elastica.yaml
  5. 15
      src/Entity/User.php
  6. 2
      src/Security/UserDTOProvider.php
  7. 102
      src/Service/NostrClient.php
  8. 1
      src/Service/RedisCacheService.php
  9. 4
      src/Twig/Components/GetCreditsComponent.php
  10. 13
      src/Twig/Components/Molecules/UserFromNpub.php
  11. 92
      src/Twig/Components/SearchComponent.php
  12. 2
      templates/components/Molecules/Card.html.twig
  13. 1
      templates/components/SearchComponent.html.twig
  14. 12
      templates/components/UserMenu.html.twig

1
assets/app.js

@ -13,6 +13,7 @@ import './styles/button.css';
import './styles/card.css'; import './styles/card.css';
import './styles/article.css'; import './styles/article.css';
import './styles/form.css'; import './styles/form.css';
import './styles/notice.css';
import './styles/spinner.css'; import './styles/spinner.css';
import './styles/a2hs.css'; import './styles/a2hs.css';

11
assets/styles/layout.css

@ -22,7 +22,7 @@
} }
nav { nav {
width: auto; width: 21vw;
min-width: 150px; min-width: 150px;
flex-shrink: 0; flex-shrink: 0;
padding: 1em; padding: 1em;
@ -104,7 +104,14 @@ main {
.user-menu { .user-menu {
position: fixed; position: fixed;
top: 180px; top: 150px;
width: 21vw;
min-width: 150px;
}
.user-nav {
padding: 10px;
margin: 10px 0;
} }
/* Right sidebar */ /* Right sidebar */

15
assets/styles/notice.css

@ -0,0 +1,15 @@
.notice {
padding: 10px; /* Padding around the content */
border-radius: 5px; /* Rounded corners */
margin: 10px 0; /* Margin above and below the notice */
}
.notice p {
margin: 0; /* Remove default paragraph margin */
}
.notice.info {
background-color: rgba(95, 115, 85, 0.15); /* Light version of --color-primary */
border-left: 4px solid var(--color-primary); /* Solid border using primary color */
color: var(--color-text); /* Use theme text color for better contrast */
}

30
config/packages/fos_elastica.yaml

@ -8,12 +8,34 @@ fos_elastica:
indexes: indexes:
# create the index by running php bin/console fos:elastica:populate # create the index by running php bin/console fos:elastica:populate
articles: articles:
settings:
index:
# Increase refresh interval for better write performance
refresh_interval: "5s"
# Optimize indexing
number_of_shards: 1
number_of_replicas: 0
analysis:
analyzer:
custom_analyzer:
type: custom
tokenizer: standard
filter: [ lowercase, snowball, asciifolding ]
indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ] indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ]
properties: properties:
createdAt: ~ createdAt:
title: ~ type: keyword
summary: ~ title:
content: ~ type: text
analyzer: custom_analyzer
content:
type: text
analyzer: custom_analyzer
summary:
type: text
analyzer: custom_analyzer
tags:
type: keyword
slug: slug:
type: keyword type: keyword
pubkey: pubkey:

15
src/Entity/User.php

@ -15,8 +15,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Table(name: "app_user")] #[ORM\Table(name: "app_user")]
class User implements UserInterface, EquatableInterface class User implements UserInterface, EquatableInterface
{ {
private static array $sessionData = [];
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@ -88,22 +86,27 @@ class User implements UserInterface, EquatableInterface
public function setMetadata(?object $metadata): void public function setMetadata(?object $metadata): void
{ {
self::$sessionData[$this->getNpub()]['metadata'] = $metadata; $this->metadata = $metadata;
} }
public function getMetadata(): ?object public function getMetadata(): ?object
{ {
return self::$sessionData[$this->getNpub()]['metadata'] ?? null; return $this->metadata;
} }
public function setRelays(?array $relays): void public function setRelays(?array $relays): void
{ {
self::$sessionData[$this->getNpub()]['relays'] = $relays; $this->relays = $relays;
} }
public function getRelays(): ?array public function getRelays(): ?array
{ {
return self::$sessionData[$this->getNpub()]['relays'] ?? null; return $this->relays;
}
public function getName(): ?string
{
return $this->getMetadata()->name ?? $this->getUserIdentifier();
} }
public function isEqualTo(UserInterface $user): bool public function isEqualTo(UserInterface $user): bool

2
src/Security/UserDTOProvider.php

@ -48,7 +48,7 @@ readonly class UserDTOProvider implements UserProviderInterface
*/ */
public function loadUserByIdentifier(string $identifier): UserInterface public function loadUserByIdentifier(string $identifier): UserInterface
{ {
$this->logger->info('Load user by identifier.'); $this->logger->info('Load user by identifier.', ['identifier' => $identifier]);
// Get or create user // Get or create user
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]); $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]);

102
src/Service/NostrClient.php

@ -42,9 +42,7 @@ class NostrClient
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
private readonly UserEntityRepository $userEntityRepository,
private readonly ArticleFactory $articleFactory, private readonly ArticleFactory $articleFactory,
private readonly SerializerInterface $serializer,
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger) private readonly LoggerInterface $logger)
{ {
@ -102,49 +100,22 @@ class NostrClient
return $reputableAuthorRelays; return $reputableAuthorRelays;
} }
public function getLoginData($npub)
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::METADATA, KindsEnum::RELAY_LIST]);
$filter->setAuthors([$npub]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
$this->logger->info('Login data.', ['response' => $response]);
// 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) {
return $filtered;
}
}
return null;
}
public function getNpubMetadata($npub): \stdClass public function getNpubMetadata($npub): \stdClass
{ {
$this->logger->info('Getting metadata for npub', ['npub' => $npub]); $this->logger->info('Getting metadata for npub', ['npub' => $npub]);
// Convert npub to hex // Npubs are converted to hex for the request down the line
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA], kinds: [KindsEnum::METADATA],
filters: ['authors' => [$pubkey]], filters: ['authors' => [$npub]]
relaySet: $this->defaultRelaySet
); );
$events = $this->processResponse($request->send(), function($received) { $events = $this->processResponse($request->send(), function($received) {
$this->logger->info('Getting metadata for npub', ['item' => $received]);
return $received; return $received;
}); });
$this->logger->info('Getting metadata for npub', ['response' => $events]);
if (empty($events)) { if (empty($events)) {
$meta = new \stdClass(); $meta = new \stdClass();
$content = new \stdClass(); $content = new \stdClass();
@ -320,69 +291,6 @@ class NostrClient
} }
} }
/**
* User metadata
* NIP-01
* @throws \Exception
*/
public function getMetadata(array $npubs): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::METADATA]);
$filter->setAuthors($npubs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// TODO make relays configurable
$relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
// $relays->addRelay(new Relay('wss://nos.lol')); // default metadata aggregator
$request = new Request($relays, $requestMessage);
$response = $request->send();
// response is an array of arrays
foreach ($response as $value) {
foreach ($value as $item) {
switch ($item->type) {
case 'EVENT':
$this->saveMetadata($item->event);
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
throw new \Exception('An error occurred');
default:
// nothing to do here
}
}
}
}
/**
* Save user metadata
*/
private function saveMetadata($metadata): void
{
try {
$user = $this->serializer->deserialize($metadata->content, User::class, 'json');
$user->setNpub($metadata->pubkey);
} catch (\Exception $e) {
$this->logger->error('Deserialization of user data failed.', ['exception' => $e]);
return;
}
try {
$this->logger->info('Saving user', ['user' => $user]);
$this->userEntityRepository->findOrCreateByUniqueField($user);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
private function saveLongFormContent(mixed $filtered): void private function saveLongFormContent(mixed $filtered): void
{ {
foreach ($filtered as $wrapper) { foreach ($filtered as $wrapper) {

1
src/Service/RedisCacheService.php

@ -25,7 +25,6 @@ readonly class RedisCacheService
public function getMetadata(string $npub): \stdClass public function getMetadata(string $npub): \stdClass
{ {
$cacheKey = '0_' . $npub; $cacheKey = '0_' . $npub;
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) { return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed

4
src/Twig/Components/GetCreditsComponent.php

@ -34,9 +34,7 @@ final class GetCreditsComponent
} }
// Dispatch event to notify parent // Dispatch event to notify parent
$this->emit('creditsAdded', [ $this->emit('creditsAdded');
'credits' => 5,
]);
} }
} }

13
src/Twig/Components/Molecules/UserFromNpub.php

@ -17,11 +17,16 @@ final class UserFromNpub
{ {
} }
public function mount(string $pubkey): void public function mount(string $ident): void
{ {
$keys = new Key(); // if npub doesn't start with 'npub' then assume it's a hex pubkey
$this->pubkey = $pubkey; if (!str_starts_with($ident, 'npub')) {
$this->npub = $keys->convertPublicKeyToBech32($pubkey); $keys = new Key();
$this->pubkey = $ident;
$this->npub = $keys->convertPublicKeyToBech32($ident);
} else {
$this->npub = $ident;
}
$this->user = $this->redisCacheService->getMetadata($this->npub); $this->user = $this->redisCacheService->getMetadata($this->npub);
} }
} }

92
src/Twig/Components/SearchComponent.php

@ -9,10 +9,13 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener; use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\Contracts\Cache\CacheInterface;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\MultiMatch;
#[AsLiveComponent] #[AsLiveComponent]
final class SearchComponent final class SearchComponent
@ -31,11 +34,18 @@ final class SearchComponent
#[LiveProp] #[LiveProp]
public int $vol = 0; public int $vol = 0;
#[LiveProp(writable: true)]
public int $page = 1;
#[LiveProp]
public int $resultsPerPage = 12;
public function __construct( public function __construct(
private readonly FinderInterface $finder, private readonly FinderInterface $finder,
private readonly CreditsManager $creditsManager, private readonly CreditsManager $creditsManager,
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger,
private readonly CacheInterface $cache
) )
{ {
$token = $this->tokenStorage->getToken(); $token = $this->tokenStorage->getToken();
@ -79,19 +89,79 @@ final class SearchComponent
return; return;
} }
$this->creditsManager->spendCredits($this->npub, 1, 'search'); try {
$this->credits--; $this->results = [];
$this->creditsManager->spendCredits($this->npub, 1, 'search');
$this->results = array_filter( $this->credits--;
$this->finder->find($this->query, 12),
fn($r) => !str_contains($r->getSlug(), '/') // Create an optimized query using collapse correctly
); $mainQuery = new Query();
// Build multi-match query for searching across fields
$multiMatch = new MultiMatch();
$multiMatch->setQuery($this->query);
$multiMatch->setFields([
'title^3',
'summary^2',
'content^1.5',
'topics'
]);
$multiMatch->setType(MultiMatch::TYPE_MOST_FIELDS);
$multiMatch->setFuzziness('AUTO');
$boolQuery = new BoolQuery();
$boolQuery->addMust($multiMatch);
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
// For text fields, we need to use a different approach
// Create a regexp query that matches content with at least 250 chars
// This is a simplification - actually matches content with enough words
$lengthFilter = new Query\QueryString();
$lengthFilter->setQuery('content:/.{250,}/');
// $boolQuery->addFilter($lengthFilter);
$mainQuery->setQuery($boolQuery);
// Use the collapse field directly in the array format
// This fixes the [collapse] failed to parse field [inner_hits] error
$mainQuery->setParam('collapse', [
'field' => 'slug',
'inner_hits' => [
'name' => 'latest_articles',
'size' => 1 // Show more related articles
]
]);
// Reduce the minimum score threshold to include more results
$mainQuery->setMinScore(0.1); // Lower minimum score
// Sort by score and createdAt
$mainQuery->setSort([
'_score' => ['order' => 'desc'],
'createdAt' => ['order' => 'desc']
]);
// Add pagination
$offset = ($this->page - 1) * $this->resultsPerPage;
$mainQuery->setFrom($offset);
$mainQuery->setSize($this->resultsPerPage);
// Execute the search
$results = $this->finder->find($mainQuery);
$this->logger->info('Search results count: ' . count($results));
$this->logger->info('Search results: ', ['results' => $results]);
$this->results = $results;
} catch (\Exception $e) {
$this->logger->error('Search error: ' . $e->getMessage());
$this->results = [];
}
} }
#[LiveListener('creditsAdded')] #[LiveListener('creditsAdded')]
public function incrementCreditsCount(array $data): void public function incrementCreditsCount(): void
{ {
$this->credits += $data['credits']; $this->credits += 5;
} }
} }

2
templates/components/Molecules/Card.html.twig

@ -4,7 +4,7 @@
{% if category %} {% if category %}
<small>{{ category }}</small> <small>{{ category }}</small>
{% else %} {% else %}
<p>by <twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></p> <p>by <twig:Molecules:UserFromNpub ident="{{ article.pubkey }}" /></p>
<small>{{ article.createdAt|date('F j Y') }}</small> <small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %} {% endif %}
</div> </div>

1
templates/components/SearchComponent.html.twig

@ -21,7 +21,6 @@
<div class="spinner" data-loading> <div class="spinner" data-loading>
<div class="lds-dual-ring"></div> <div class="lds-dual-ring"></div>
</div> </div>
<span data-loading>{{ 'text.searching'|trans }}</span>
</div> </div>
{% endif %} {% endif %}

12
templates/components/UserMenu.html.twig

@ -1,6 +1,9 @@
<div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}> <div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}>
{% if app.user %} {% if app.user %}
<p>Hello, {{ app.user.name }}</p> <div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
</div>
{# <p>Hello, {{ app.user.name }}</p>#}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
{# <ul>#} {# <ul>#}
{# <li>#} {# <li>#}
@ -8,7 +11,7 @@
{# </li>#} {# </li>#}
{# </ul>#} {# </ul>#}
{% endif %} {% endif %}
<ul> <ul class="user-nav">
<li> <li>
<a href="{{ path('author-profile', {npub: app.user.npub }) }}">Profile</a> <a href="{{ path('author-profile', {npub: app.user.npub }) }}">Profile</a>
</li> </li>
@ -29,9 +32,12 @@
</li> </li>
</ul> </ul>
{% else %} {% else %}
<div class="notice info">
<p>Log in to access search.</p>
</div>
<twig:Atoms:Button {{ ...stimulus_action('login', 'loginAct') }}>{{ 'heading.logIn'|trans }}</twig:Atoms:Button> <twig:Atoms:Button {{ ...stimulus_action('login', 'loginAct') }}>{{ 'heading.logIn'|trans }}</twig:Atoms:Button>
{% endif %} {% endif %}
<div style="text-align: center"> <div>
<div class="spinner" data-loading> <div class="spinner" data-loading>
<div class="lds-dual-ring"></div> <div class="lds-dual-ring"></div>
</div> </div>

Loading…
Cancel
Save