76 changed files with 844 additions and 295 deletions
@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
import { getSigner } from './signer_manager.js'; |
||||
import { getSigner } from '../nostr/signer_manager.js'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = ['dropdown', 'status', 'menu']; |
||||
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
// Shared signer manager for Nostr signers (remote and extension)
|
||||
import { SimplePool } from 'nostr-tools'; |
||||
import { BunkerSigner } from 'nostr-tools/nip46'; |
||||
|
||||
const REMOTE_SIGNER_KEY = 'amber_remote_signer'; |
||||
|
||||
let remoteSigner = null; |
||||
let remoteSignerPromise = null; |
||||
let remoteSignerPool = null; |
||||
|
||||
export async function getSigner(_retrying = 0) { |
||||
// If remote signer session is active, use it
|
||||
const session = getRemoteSignerSession(); |
||||
console.log('[signer_manager] getSigner called, session exists:', !!session); |
||||
if (session) { |
||||
if (remoteSigner) { |
||||
console.log('[signer_manager] Returning cached remote signer'); |
||||
return remoteSigner; |
||||
} |
||||
if (remoteSignerPromise) { |
||||
console.log('[signer_manager] Returning existing connection promise'); |
||||
return remoteSignerPromise; |
||||
} |
||||
|
||||
console.log('[signer_manager] Recreating BunkerSigner from stored session (no connect needed)...'); |
||||
// According to nostr-tools docs: BunkerSigner.fromURI() returns immediately
|
||||
// After initial connect() during login, we can reuse the signer without reconnecting
|
||||
remoteSignerPromise = createRemoteSignerFromSession(session) |
||||
.then(signer => { |
||||
remoteSigner = signer; |
||||
console.log('[signer_manager] Remote signer successfully recreated and cached'); |
||||
return signer; |
||||
}) |
||||
.catch((error) => { |
||||
console.error('[signer_manager] Remote signer creation failed:', error); |
||||
remoteSignerPromise = null; |
||||
// Clear stale session
|
||||
console.log('[signer_manager] Clearing stale remote signer session'); |
||||
clearRemoteSignerSession(); |
||||
// Fallback to browser extension if available
|
||||
if (window.nostr && typeof window.nostr.signEvent === 'function') { |
||||
console.log('[signer_manager] Falling back to browser extension'); |
||||
return window.nostr; |
||||
} |
||||
throw new Error('Remote signer unavailable. Please reconnect Amber or use a browser extension.'); |
||||
}); |
||||
return remoteSignerPromise; |
||||
} |
||||
// Fallback to browser extension ONLY if no remote session
|
||||
console.log('[signer_manager] No remote session, checking for browser extension'); |
||||
if (window.nostr && typeof window.nostr.signEvent === 'function') { |
||||
console.log('[signer_manager] Using browser extension'); |
||||
return window.nostr; |
||||
} |
||||
throw new Error('No signer available'); |
||||
} |
||||
|
||||
export function setRemoteSignerSession(session) { |
||||
localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session)); |
||||
} |
||||
|
||||
export function clearRemoteSignerSession() { |
||||
localStorage.removeItem(REMOTE_SIGNER_KEY); |
||||
remoteSigner = null; |
||||
remoteSignerPromise = null; |
||||
if (remoteSignerPool) { |
||||
try { remoteSignerPool.close?.([]); } catch (_) {} |
||||
remoteSignerPool = null; |
||||
} |
||||
} |
||||
|
||||
export function getRemoteSignerSession() { |
||||
const raw = localStorage.getItem(REMOTE_SIGNER_KEY); |
||||
if (!raw) return null; |
||||
try { |
||||
return JSON.parse(raw); |
||||
} catch { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
// Create BunkerSigner from stored session
|
||||
// According to nostr-tools: fromURI() returns immediately, no waiting for handshake
|
||||
// The connect() was already done during initial login, so we can use the signer right away
|
||||
async function createRemoteSignerFromSession(session) { |
||||
console.log('[signer_manager] ===== Recreating BunkerSigner from session ====='); |
||||
console.log('[signer_manager] Session URI:', session.uri); |
||||
console.log('[signer_manager] Session relays:', session.relays); |
||||
|
||||
// Reuse existing pool if available, otherwise create new one
|
||||
if (!remoteSignerPool) { |
||||
console.log('[signer_manager] Creating new SimplePool for relays:', session.relays); |
||||
remoteSignerPool = new SimplePool(); |
||||
} else { |
||||
console.log('[signer_manager] Reusing existing SimplePool'); |
||||
} |
||||
|
||||
try { |
||||
console.log('[signer_manager] Creating BunkerSigner from stored session...'); |
||||
// fromURI returns a Promise - await it to get the signer
|
||||
const signer = await BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }); |
||||
console.log('[signer_manager] ✅ BunkerSigner created! Testing with getPublicKey...'); |
||||
|
||||
// Test the signer to make sure it works
|
||||
try { |
||||
const pubkey = await signer.getPublicKey(); |
||||
console.log('[signer_manager] ✅ Signer verified! Pubkey:', pubkey); |
||||
return signer; |
||||
} catch (testError) { |
||||
console.error('[signer_manager] ❌ Signer test failed:', testError); |
||||
throw new Error('Signer created but failed verification: ' + testError.message); |
||||
} |
||||
} catch (error) { |
||||
console.error('[signer_manager] ❌ Failed to create signer:', error); |
||||
// Clean up on error
|
||||
if (remoteSignerPool) { |
||||
try { |
||||
console.log('[signer_manager] Closing pool after error'); |
||||
remoteSignerPool.close?.([]); |
||||
} catch (_) {} |
||||
remoteSignerPool = null; |
||||
} |
||||
remoteSigner = null; |
||||
remoteSignerPromise = null; |
||||
throw error; |
||||
} |
||||
} |
||||
@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
|
||||
// Shared signer manager for Nostr signers (remote and extension)
|
||||
import { SimplePool } from 'nostr-tools'; |
||||
import { BunkerSigner } from 'nostr-tools/nip46'; |
||||
|
||||
const REMOTE_SIGNER_KEY = 'amber_remote_signer'; |
||||
|
||||
let remoteSigner = null; |
||||
let remoteSignerPromise = null; |
||||
let remoteSignerPool = null; |
||||
|
||||
export async function getSigner() { |
||||
// If remote signer session is active, use it
|
||||
const session = getRemoteSignerSession(); |
||||
if (session) { |
||||
if (remoteSigner) return remoteSigner; |
||||
if (remoteSignerPromise) return remoteSignerPromise; |
||||
|
||||
remoteSignerPromise = createRemoteSigner(session) |
||||
.then(signer => { |
||||
remoteSigner = signer; |
||||
return signer; |
||||
}) |
||||
.catch(error => { |
||||
// Reset promise on failure so next call can retry
|
||||
remoteSignerPromise = null; |
||||
throw error; |
||||
}); |
||||
return remoteSignerPromise; |
||||
} |
||||
// Fallback to browser extension
|
||||
if (window.nostr && typeof window.nostr.signEvent === 'function') { |
||||
return window.nostr; |
||||
} |
||||
throw new Error('No signer available'); |
||||
} |
||||
|
||||
export function setRemoteSignerSession(session) { |
||||
localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session)); |
||||
} |
||||
|
||||
export function clearRemoteSignerSession() { |
||||
localStorage.removeItem(REMOTE_SIGNER_KEY); |
||||
remoteSigner = null; |
||||
remoteSignerPromise = null; |
||||
if (remoteSignerPool) { |
||||
try { remoteSignerPool.close?.([]); } catch (_) {} |
||||
remoteSignerPool = null; |
||||
} |
||||
} |
||||
|
||||
export function getRemoteSignerSession() { |
||||
const raw = localStorage.getItem(REMOTE_SIGNER_KEY); |
||||
if (!raw) return null; |
||||
try { |
||||
return JSON.parse(raw); |
||||
} catch { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
async function createRemoteSigner(session) { |
||||
remoteSignerPool = new SimplePool(); |
||||
|
||||
// Add timeout to prevent hanging indefinitely
|
||||
const timeoutPromise = new Promise((_, reject) => { |
||||
setTimeout(() => reject(new Error('Remote signer connection timeout')), 10000); |
||||
}); |
||||
|
||||
try { |
||||
return await Promise.race([ |
||||
BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }), |
||||
timeoutPromise |
||||
]); |
||||
} catch (error) { |
||||
// Clean up on error
|
||||
if (remoteSignerPool) { |
||||
try { remoteSignerPool.close?.([]); } catch (_) {} |
||||
remoteSignerPool = null; |
||||
} |
||||
remoteSigner = null; |
||||
remoteSignerPromise = null; |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
<?php |
||||
|
||||
namespace App\Service\Search; |
||||
|
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
class ArticleSearchFactory |
||||
{ |
||||
public function __construct( |
||||
private readonly ElasticsearchArticleSearch $elasticsearchSearch, |
||||
private readonly DatabaseArticleSearch $databaseSearch, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly bool $elasticsearchEnabled |
||||
) { |
||||
} |
||||
|
||||
public function create(): ArticleSearchInterface |
||||
{ |
||||
if ($this->elasticsearchEnabled && $this->elasticsearchSearch->isAvailable()) { |
||||
$this->logger->info('Using Elasticsearch for article search'); |
||||
return $this->elasticsearchSearch; |
||||
} |
||||
|
||||
$this->logger->info('Using database for article search'); |
||||
return $this->databaseSearch; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
<?php |
||||
|
||||
namespace App\Service\Search; |
||||
|
||||
use App\Entity\Article; |
||||
|
||||
interface ArticleSearchInterface |
||||
{ |
||||
/** |
||||
* Search for articles matching the given query |
||||
* |
||||
* @param string $query The search query |
||||
* @param int $limit Maximum number of results |
||||
* @param int $offset Offset for pagination |
||||
* @return Article[] |
||||
*/ |
||||
public function search(string $query, int $limit = 12, int $offset = 0): array; |
||||
|
||||
/** |
||||
* Find articles by slugs |
||||
* |
||||
* @param array $slugs Array of article slugs |
||||
* @param int $limit Maximum number of results |
||||
* @return Article[] |
||||
*/ |
||||
public function findBySlugs(array $slugs, int $limit = 200): array; |
||||
|
||||
/** |
||||
* Find articles by topics |
||||
* |
||||
* @param array $topics Array of topics |
||||
* @param int $limit Maximum number of results |
||||
* @param int $offset Offset for pagination |
||||
* @return Article[] |
||||
*/ |
||||
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array; |
||||
|
||||
/** |
||||
* Find articles by pubkey (author) |
||||
* |
||||
* @param string $pubkey Author's public key |
||||
* @param int $limit Maximum number of results |
||||
* @param int $offset Offset for pagination |
||||
* @return Article[] |
||||
*/ |
||||
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array; |
||||
|
||||
/** |
||||
* Check if the search service is available |
||||
* |
||||
* @return bool |
||||
*/ |
||||
public function isAvailable(): bool; |
||||
} |
||||
|
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
<?php |
||||
|
||||
namespace App\Service\Search; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Repository\ArticleRepository; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
class DatabaseArticleSearch implements ArticleSearchInterface |
||||
{ |
||||
public function __construct( |
||||
private readonly ArticleRepository $articleRepository, |
||||
private readonly LoggerInterface $logger |
||||
) { |
||||
} |
||||
|
||||
public function search(string $query, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
try { |
||||
$results = $this->articleRepository->searchByQuery($query, $limit, $offset); |
||||
$this->logger->info('Database search results count: ' . count($results)); |
||||
return $results; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Database search error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findBySlugs(array $slugs, int $limit = 200): array |
||||
{ |
||||
if (empty($slugs)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
return $this->articleRepository->findBySlugs($slugs, $limit); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Database findBySlugs error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
if (empty($topics)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
return $this->articleRepository->findByTopics($topics, $limit, $offset); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Database findByTopics error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
if (empty($pubkey)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
return $this->articleRepository->findByPubkey($pubkey, $limit, $offset); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Database findByPubkey error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function isAvailable(): bool |
||||
{ |
||||
return true; // Database is always available |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
|
||||
<?php |
||||
|
||||
namespace App\Service\Search; |
||||
|
||||
use App\Entity\Article; |
||||
use Elastica\Query; |
||||
use Elastica\Query\BoolQuery; |
||||
use Elastica\Query\MultiMatch; |
||||
use Elastica\Query\Terms; |
||||
use FOS\ElasticaBundle\Finder\FinderInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
class ElasticsearchArticleSearch implements ArticleSearchInterface |
||||
{ |
||||
public function __construct( |
||||
private readonly FinderInterface $finder, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly bool $enabled = true |
||||
) { |
||||
} |
||||
|
||||
public function search(string $query, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
if (!$this->enabled) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
$mainQuery = new Query(); |
||||
$boolQuery = new BoolQuery(); |
||||
|
||||
// Add phrase match for exact matches (high boost) |
||||
$phraseMatch = new Query\MatchPhrase(); |
||||
$phraseMatch->setField('search_combined', [ |
||||
'query' => $query, |
||||
'boost' => 10 |
||||
]); |
||||
$boolQuery->addShould($phraseMatch); |
||||
|
||||
// Main multi-match query with optimized settings |
||||
$multiMatch = new MultiMatch(); |
||||
$multiMatch->setQuery($query); |
||||
$multiMatch->setFields(['search_combined']); |
||||
$multiMatch->setFuzziness('AUTO'); |
||||
$boolQuery->addMust($multiMatch); |
||||
|
||||
// Exclude specific patterns |
||||
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); |
||||
|
||||
$mainQuery->setQuery($boolQuery); |
||||
|
||||
// Simplified collapse - no inner_hits for better performance |
||||
$mainQuery->setParam('collapse', [ |
||||
'field' => 'slug' |
||||
]); |
||||
|
||||
// Lower minimum score for better recall |
||||
$mainQuery->setMinScore(0.25); |
||||
|
||||
// Sort by score first, then date |
||||
$mainQuery->setSort([ |
||||
'_score' => ['order' => 'desc'], |
||||
'createdAt' => ['order' => 'desc'] |
||||
]); |
||||
|
||||
$mainQuery->setFrom($offset); |
||||
$mainQuery->setSize($limit); |
||||
|
||||
// Execute the search |
||||
$results = $this->finder->find($mainQuery); |
||||
$this->logger->info('Elasticsearch search results count: ' . count($results)); |
||||
|
||||
return $results; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Elasticsearch search error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findBySlugs(array $slugs, int $limit = 200): array |
||||
{ |
||||
if (!$this->enabled || empty($slugs)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
$termsQuery = new Terms('slug', array_values($slugs)); |
||||
$query = new Query($termsQuery); |
||||
$query->setSize($limit); |
||||
|
||||
return $this->finder->find($query); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Elasticsearch findBySlugs error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
if (!$this->enabled || empty($topics)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
$boolQuery = new BoolQuery(); |
||||
$termsQuery = new Terms('topics', $topics); |
||||
$boolQuery->addMust($termsQuery); |
||||
|
||||
// Exclude specific patterns |
||||
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); |
||||
|
||||
$mainQuery = new Query($boolQuery); |
||||
$mainQuery->setSort([ |
||||
'createdAt' => ['order' => 'desc'] |
||||
]); |
||||
$mainQuery->setFrom($offset); |
||||
$mainQuery->setSize($limit); |
||||
|
||||
return $this->finder->find($mainQuery); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Elasticsearch findByTopics error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array |
||||
{ |
||||
if (!$this->enabled || empty($pubkey)) { |
||||
return []; |
||||
} |
||||
|
||||
try { |
||||
$boolQuery = new BoolQuery(); |
||||
$termQuery = new Query\Term(); |
||||
$termQuery->setTerm('pubkey', $pubkey); |
||||
$boolQuery->addMust($termQuery); |
||||
|
||||
// Exclude specific patterns |
||||
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); |
||||
|
||||
$mainQuery = new Query($boolQuery); |
||||
$mainQuery->setSort([ |
||||
'createdAt' => ['order' => 'desc'] |
||||
]); |
||||
$mainQuery->setFrom($offset); |
||||
$mainQuery->setSize($limit); |
||||
|
||||
return $this->finder->find($mainQuery); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Elasticsearch findByPubkey error: ' . $e->getMessage()); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
public function isAvailable(): bool |
||||
{ |
||||
return $this->enabled; |
||||
} |
||||
} |
||||
|
||||
@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
|
||||
<header class="header" data-controller="menu" {{ attributes }}> |
||||
<header class="header" data-controller="ui--menu" {{ attributes }}> |
||||
<div class="container"> |
||||
<div class="mobile-toggles" data-controller="sidebar-toggle"> |
||||
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">☰</button> |
||||
<div class="mobile-toggles" data-controller="ui--sidebar-toggle"> |
||||
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->ui--sidebar-toggle#toggle">☰</button> |
||||
</div> |
||||
<div class="header__logo"> |
||||
<h1 class="brand"><a href="/">Decent Newsroom</a></h1> |
||||
</div> |
||||
</div> |
||||
<div data-controller="progress-bar"> |
||||
<div id="progress-bar" data-progress-bar-target="bar"></div> |
||||
<div data-controller="ui--progress-bar"> |
||||
<div id="progress-bar" data-ui--progress-bar-target="bar"></div> |
||||
</div> |
||||
</header> |
||||
|
||||
Loading…
Reference in new issue