Browse Source

Messages, Mercure, Caching, Auth

imwald
Nuša Pukšič 3 months ago
parent
commit
aa8c13f0d7
  1. 4
      Dockerfile
  2. 124
      assets/controllers/comments_mercure_controller.js
  3. 31
      compose.yaml
  4. 53
      composer.json
  5. 1057
      composer.lock
  6. 4
      config/packages/cache.yaml
  7. 2
      config/packages/messenger.yaml
  8. 1
      config/packages/twig.yaml
  9. 1
      config/services.yaml
  10. 10
      frankenphp/Caddyfile
  11. 4
      src/Controller/Administration/MagazineAdminController.php
  12. 16
      src/Controller/MagazineWizardController.php
  13. 19
      src/Message/FetchCommentsMessage.php
  14. 67
      src/MessageHandler/FetchCommentsHandler.php
  15. 4
      src/Service/Nip05VerificationService.php
  16. 50
      src/Service/NostrClient.php
  17. 32
      src/Service/RedisCacheService.php
  18. 36
      src/Twig/Components/Organisms/Comments.php
  19. 25
      src/Util/NostrPhp/TweakedRequest.php
  20. 12
      symfony.lock
  21. 1
      templates/base.html.twig
  22. 33
      templates/components/Organisms/Comments.html.twig

4
Dockerfile

@ -37,6 +37,7 @@ RUN set -eux; \
gmp \ gmp \
gd \ gd \
redis \ redis \
pcntl \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
@ -100,5 +101,4 @@ RUN set -eux; \
composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \ composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \ composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync; \ chmod +x bin/console; sync;
docker exec newsroom-php-1 php bin/console asset-map:compile;

124
assets/controllers/comments_mercure_controller.js

@ -0,0 +1,124 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="comments-mercure"
export default class extends Controller {
static values = {
coordinate: String
}
static targets = ["list", "loading"];
connect() {
const coordinate = this.coordinateValue;
const topic = `/comments/${coordinate}`;
const hubUrl = window.MercureHubUrl || (document.querySelector('meta[name="mercure-hub"]')?.content);
console.log('[comments-mercure] connect', { coordinate, topic, hubUrl });
if (!hubUrl) return;
const url = new URL(hubUrl);
url.searchParams.append('topic', topic);
this.eventSource = new EventSource(url.toString());
this.eventSource.onopen = () => {
console.log('[comments-mercure] EventSource opened', url.toString());
};
this.eventSource.onerror = (e) => {
console.error('[comments-mercure] EventSource error', e);
};
this.eventSource.onmessage = (event) => {
console.log('[comments-mercure] Event received', event.data);
const data = JSON.parse(event.data);
this.profiles = data.profiles || {};
if (this.hasLoadingTarget) this.loadingTarget.style.display = 'none';
if (this.hasListTarget) {
if (data.comments && data.comments.length > 0) {
this.listTarget.innerHTML = data.comments.map((item) => {
const zapData = this.parseZapAmount(item) || {};
const zapAmount = zapData.amount;
const zapperPubkey = zapData.zapper;
const parsedContent = this.parseContent(item.content);
const isZap = item.kind === 9735;
const displayPubkey = isZap ? (zapperPubkey || item.pubkey) : item.pubkey;
const profile = this.profiles[displayPubkey];
const displayName = profile?.name || displayPubkey;
return `<div class="card comment ${isZap ? 'zap-comment' : ''}">
<div class="metadata">
<span><a href="/p/${displayPubkey}">${displayName}</a></span>
<small>${item.created_at ? new Date(item.created_at * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : ''}</small>
</div>
<div class="card-body">
${isZap ? `<div class="zap-amount">${zapAmount ? `<strong>${zapAmount} sat</strong>` : '<em>Zap</em>'}</div>` : ''}
<div>${parsedContent}</div>
</div>
</div>`;
}).join('');
} else {
this.listTarget.innerHTML = '<div class="no-comments">No comments yet.</div>';
}
this.listTarget.style.display = '';
}
};
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
console.log('[comments-mercure] EventSource closed');
}
}
parseContent(content) {
if (!content) return '';
// Escape HTML to prevent XSS
let html = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Parse URLs
html = html.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
// Parse Nostr npub
html = html.replace(/\b(npub1[a-z0-9]+)\b/g, '<a href="/user/$1">$1</a>');
// Parse Nostr nevent
html = html.replace(/\b(nevent1[a-z0-9]+)\b/g, '<a href="/event/$1">$1</a>');
// Parse Nostr nprofile
html = html.replace(/\b(nprofile1[a-z0-9]+)\b/g, '<a href="/profile/$1">$1</a>');
// Parse Nostr note
html = html.replace(/\b(note1[a-z0-9]+)\b/g, '<a href="/note/$1">$1</a>');
return html;
}
parseZapAmount(item) {
if (item.kind !== 9735) return null;
const tags = item.tags || [];
let amount = null;
let zapper = null;
// Find zapper from 'p' tag
const pTag = tags.find(tag => tag[0] === 'p');
if (pTag && pTag[1]) {
zapper = pTag[1];
}
// Find amount in 'amount' tag (msat)
const amountTag = tags.find(tag => tag[0] === 'amount');
if (amountTag && amountTag[1]) {
const msat = parseInt(amountTag[1], 10);
amount = Math.floor(msat / 1000); // Convert to sat
}
// Fallback to description for content
const descTag = tags.find(tag => tag[0] === 'description');
if (descTag && descTag[1]) {
try {
const desc = JSON.parse(descTag[1]);
if (desc.content) {
item.content = desc.content; // Update content
}
} catch (e) {}
}
return { amount, zapper };
}
}

31
compose.yaml

@ -7,10 +7,11 @@ services:
environment: environment:
APP_ENV: ${APP_ENV:-dev} APP_ENV: ${APP_ENV:-dev}
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} MERCURE_PUBLISHER_JWT_ALG: ${MERCURE_PUBLISHER_JWT_ALG:-HS256}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} MERCURE_SUBSCRIBER_JWT_ALG: ${MERCURE_SUBSCRIBER_JWT_ALG:-HS256}
# Removed overrides for JWT keys to use values from .env
# Run "composer require symfony/orm-pack" to install and configure Doctrine ORM # Run "composer require symfony/orm-pack" to install and configure Doctrine ORM
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8} DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}
# Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration # Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration
MERCURE_URL: ${MERCURE_URL:-http://php/.well-known/mercure} MERCURE_URL: ${MERCURE_URL:-http://php/.well-known/mercure}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure} MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure}
@ -63,6 +64,30 @@ services:
depends_on: depends_on:
- php - php
worker:
build:
context: .
dockerfile: Dockerfile
working_dir: /app
entrypoint: ["php"] # run PHP CLI, not Caddy/FrankenPHP
command:
- bin/console
- messenger:consume
- -vv
- --memory-limit=256M
- --keepalive
- "10"
- async
restart: unless-stopped
depends_on:
- php
- database
# IMPORTANT: ensure it does NOT bind ports or mount caddy volumes
ports: []
expose: []
volumes:
- .:/app
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:

53
composer.json

@ -27,32 +27,33 @@
"phpstan/phpdoc-parser": "^2.0", "phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.5", "swentel/nostr-php": "^1.5",
"symfony/asset": "7.1.*", "symfony/asset": "7.2.*",
"symfony/asset-mapper": "7.1.*", "symfony/asset-mapper": "7.2.*",
"symfony/console": "7.1.*", "symfony/console": "7.2.*",
"symfony/dotenv": "7.1.*", "symfony/dotenv": "7.2.*",
"symfony/expression-language": "7.1.*", "symfony/expression-language": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.1.*", "symfony/form": "7.2.*",
"symfony/framework-bundle": "7.1.*", "symfony/framework-bundle": "7.2.*",
"symfony/html-sanitizer": "7.1.*", "symfony/html-sanitizer": "7.2.*",
"symfony/http-foundation": "7.1.*", "symfony/http-foundation": "7.2.*",
"symfony/intl": "7.1.*", "symfony/intl": "7.2.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.1.*", "symfony/messenger": "7.2.*",
"symfony/mime": "7.1.*", "symfony/mime": "7.2.*",
"symfony/property-access": "7.1.*", "symfony/property-access": "7.2.*",
"symfony/property-info": "7.1.*", "symfony/property-info": "7.2.*",
"symfony/runtime": "7.1.*", "symfony/redis-messenger": "7.2.*",
"symfony/security-bundle": "7.1.*", "symfony/runtime": "7.2.*",
"symfony/serializer": "7.1.*", "symfony/security-bundle": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/stimulus-bundle": "^2.22", "symfony/stimulus-bundle": "^2.22",
"symfony/translation": "7.1.*", "symfony/translation": "7.2.*",
"symfony/twig-bundle": "7.1.*", "symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.22", "symfony/ux-icons": "^2.22",
"symfony/ux-live-component": "^2.21", "symfony/ux-live-component": "^2.21",
"symfony/workflow": "7.1.*", "symfony/workflow": "7.2.*",
"symfony/yaml": "7.1.*", "symfony/yaml": "7.2.*",
"tkijewski/php-lnurl": "*", "tkijewski/php-lnurl": "*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.21", "twig/markdown-extra": "^3.21",
@ -107,7 +108,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.1.*", "require": "7.2.*",
"docker": true "docker": true
}, },
"runtime": { "runtime": {
@ -116,11 +117,11 @@
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.1.*", "symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.1.*", "symfony/css-selector": "7.2.*",
"symfony/maker-bundle": "^1.63", "symfony/maker-bundle": "^1.63",
"symfony/phpunit-bridge": "^7.2", "symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.1.*", "symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.1.*" "symfony/web-profiler-bundle": "7.2.*"
} }
} }

1057
composer.lock generated

File diff suppressed because it is too large Load Diff

4
config/packages/cache.yaml

@ -20,3 +20,7 @@ framework:
adapter: cache.adapter.redis adapter: cache.adapter.redis
provider: Redis provider: Redis
default_lifetime: 3600 default_lifetime: 3600
npub.cache:
adapter: cache.adapter.redis
provider: Redis
default_lifetime: 3600

2
config/packages/messenger.yaml

@ -12,7 +12,7 @@ framework:
routing: routing:
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async 'App\Message\FetchCommentsMessage': async
# when@test: # when@test:
# framework: # framework:

1
config/packages/twig.yaml

@ -4,6 +4,7 @@ twig:
project_npub: 'npub1ez09adke4vy8udk3y2skwst8q5chjgqzym9lpq4u58zf96zcl7kqyry2lz' project_npub: 'npub1ez09adke4vy8udk3y2skwst8q5chjgqzym9lpq4u58zf96zcl7kqyry2lz'
dev_npub: 'npub1636uujeewag8zv8593lcvdrwlymgqre6uax4anuq3y5qehqey05sl8qpl4' dev_npub: 'npub1636uujeewag8zv8593lcvdrwlymgqre6uax4anuq3y5qehqey05sl8qpl4'
feature_flag_share_btn: false feature_flag_share_btn: false
mercure_public_hub_url: '%mercure_public_hub_url%'
when@test: when@test:
twig: twig:

1
config/services.yaml

@ -5,6 +5,7 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
encryption_key: '%env(APP_ENCRYPTION_KEY)%' encryption_key: '%env(APP_ENCRYPTION_KEY)%'
mercure_public_hub_url: '%env(MERCURE_PUBLIC_URL)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file

10
frankenphp/Caddyfile

@ -1,9 +1,7 @@
{ {$CADDY_GLOBAL_OPTIONS}
{$CADDY_GLOBAL_OPTIONS}
frankenphp { frankenphp {
{$FRANKENPHP_CONFIG} {$FRANKENPHP_CONFIG}
}
} }
{$CADDY_EXTRA_CONFIG} {$CADDY_EXTRA_CONFIG}
@ -26,9 +24,7 @@
# Transport to use (default to Bolt) # Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
# Publisher JWT key # Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} publisher_jwt {env.MERCURE_JWT_SECRET} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
# Allow anonymous subscribers (double-check that it's what you want) # Allow anonymous subscribers (double-check that it's what you want)
anonymous anonymous
# Enable the subscription API (double-check that it's what you want) # Enable the subscription API (double-check that it's what you want)

4
src/Controller/Administration/MagazineAdminController.php

@ -20,10 +20,10 @@ class MagazineAdminController extends AbstractController
{ {
#[Route('/admin/magazines', name: 'admin_magazines')] #[Route('/admin/magazines', name: 'admin_magazines')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
public function index(RedisClient $redis, CacheInterface $redisCache, EntityManagerInterface $em): Response public function index(RedisClient $redis, CacheInterface $appCache, EntityManagerInterface $em): Response
{ {
// Optimized database-first approach // Optimized database-first approach
$magazines = $this->getMagazinesFromDatabase($em, $redis, $redisCache); $magazines = $this->getMagazinesFromDatabase($em, $redis, $appCache);
return $this->render('admin/magazines.html.twig', [ return $this->render('admin/magazines.html.twig', [
'magazines' => $magazines, 'magazines' => $magazines,

16
src/Controller/MagazineWizardController.php

@ -190,7 +190,7 @@ class MagazineWizardController extends AbstractController
#[Route('/api/index/publish', name: 'api-index-publish', methods: ['POST'])] #[Route('/api/index/publish', name: 'api-index-publish', methods: ['POST'])]
public function publishIndexEvent( public function publishIndexEvent(
Request $request, Request $request,
CacheItemPoolInterface $redisCache, CacheItemPoolInterface $appCache,
CsrfTokenManagerInterface $csrfTokenManager, CsrfTokenManagerInterface $csrfTokenManager,
RedisClient $redis, RedisClient $redis,
EntityManagerInterface $entityManager EntityManagerInterface $entityManager
@ -237,9 +237,9 @@ class MagazineWizardController extends AbstractController
// Save to Redis under magazine-<slug> // Save to Redis under magazine-<slug>
try { try {
$key = 'magazine-' . $slug; $key = 'magazine-' . $slug;
$item = $redisCache->getItem($key); $item = $appCache->getItem($key);
$item->set($eventObj); $item->set($eventObj);
$redisCache->save($item); $appCache->save($item);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return new JsonResponse(['error' => 'Redis error'], 500); return new JsonResponse(['error' => 'Redis error'], 500);
} }
@ -282,7 +282,7 @@ class MagazineWizardController extends AbstractController
CsrfTokenManagerInterface $csrfTokenManager, CsrfTokenManagerInterface $csrfTokenManager,
NzineRepository $nzineRepository, NzineRepository $nzineRepository,
EncryptionService $encryptionService, EncryptionService $encryptionService,
CacheItemPoolInterface $redisCache, CacheItemPoolInterface $appCache,
RedisClient $redis, RedisClient $redis,
EntityManagerInterface $entityManager EntityManagerInterface $entityManager
): JsonResponse { ): JsonResponse {
@ -347,9 +347,9 @@ class MagazineWizardController extends AbstractController
// Save to Redis // Save to Redis
$cacheKey = 'magazine-' . $slug; $cacheKey = 'magazine-' . $slug;
$item = $redisCache->getItem($cacheKey); $item = $appCache->getItem($cacheKey);
$item->set($catEvent); $item->set($catEvent);
$redisCache->save($item); $appCache->save($item);
// Save to database // Save to database
$eventEntity = new \App\Entity\Event(); $eventEntity = new \App\Entity\Event();
@ -396,9 +396,9 @@ class MagazineWizardController extends AbstractController
// Save magazine to Redis // Save magazine to Redis
$cacheKey = 'magazine-' . $magSlug; $cacheKey = 'magazine-' . $magSlug;
$item = $redisCache->getItem($cacheKey); $item = $appCache->getItem($cacheKey);
$item->set($magEvent); $item->set($magEvent);
$redisCache->save($item); $appCache->save($item);
// Save magazine to database // Save magazine to database
$magEventEntity = new \App\Entity\Event(); $magEventEntity = new \App\Entity\Event();

19
src/Message/FetchCommentsMessage.php

@ -0,0 +1,19 @@
<?php
namespace App\Message;
class FetchCommentsMessage
{
private string $coordinate;
public function __construct(string $coordinate)
{
$this->coordinate = $coordinate;
}
public function getCoordinate(): string
{
return $this->coordinate;
}
}

67
src/MessageHandler/FetchCommentsHandler.php

@ -0,0 +1,67 @@
<?php
namespace App\MessageHandler;
use App\Message\FetchCommentsMessage;
use App\Service\NostrClient;
use App\Service\NostrLinkParser;
use App\Service\RedisCacheService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
#[AsMessageHandler]
class FetchCommentsHandler
{
public function __construct(
private readonly NostrClient $nostrClient,
private readonly NostrLinkParser $nostrLinkParser,
private readonly RedisCacheService $redisCacheService,
private readonly HubInterface $hub,
private readonly LoggerInterface $logger
) {}
public function __invoke(FetchCommentsMessage $message): void
{
$coordinate = $message->getCoordinate();
$comments = $this->nostrClient->getComments($coordinate);
// Collect all pubkeys: authors and zappers
$allPubKeys = [];
foreach ($comments as $c) {
$allPubKeys[] = $c->pubkey;
if ($c->kind == 9735) {
$tags = $c->tags ?? [];
foreach ($tags as $tag) {
if ($tag[0] === 'p' && isset($tag[1])) {
$allPubKeys[] = $tag[1];
}
}
}
}
$allPubKeys = array_unique($allPubKeys);
$authorsMetadata = $this->redisCacheService->getMultipleMetadata($allPubKeys);
$this->logger->info('Fetched ' . count($comments) . ' comments for coordinate: ' . $coordinate);
$this->logger->info('Fetched ' . count($authorsMetadata) . ' profiles for ' . count($allPubKeys) . ' pubkeys');
usort($comments, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0));
// Optionally, reuse parseNostrLinks and parseZaps logic here if needed
// For now, just send the raw comments array
$data = [
'coordinate' => $coordinate,
'comments' => $comments,
'profiles' => $authorsMetadata
];
try {
$topic = "/comments/" . $coordinate;
$update = new Update($topic, json_encode($data), false);
$this->logger->info('Publishing comments update for coordinate: ' . $coordinate);
$this->hub->publish($update);
} catch (\Exception $e) {
// Handle exception (log it, etc.)
$this->logger->error('Error publishing comments update: ' . $e->getMessage());
}
}
}

4
src/Service/Nip05VerificationService.php

@ -12,7 +12,7 @@ readonly class Nip05VerificationService
private const REQUEST_TIMEOUT = 5; // 5 seconds private const REQUEST_TIMEOUT = 5; // 5 seconds
public function __construct( public function __construct(
private CacheInterface $redisCache, private CacheInterface $appCache,
private LoggerInterface $logger private LoggerInterface $logger
) { ) {
} }
@ -39,7 +39,7 @@ readonly class Nip05VerificationService
$cacheKey = 'nip05_' . md5($nip05); $cacheKey = 'nip05_' . md5($nip05);
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($localPart, $domain, $pubkeyHex, $nip05) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($localPart, $domain, $pubkeyHex, $nip05) {
$item->expiresAfter(self::CACHE_TTL); $item->expiresAfter(self::CACHE_TTL);
$wellKnownUrl = "https://{$domain}/.well-known/nostr.json?name=" . urlencode(strtolower($localPart)); $wellKnownUrl = "https://{$domain}/.well-known/nostr.json?name=" . urlencode(strtolower($localPart));

50
src/Service/NostrClient.php

@ -9,6 +9,7 @@ use App\Util\NostrPhp\TweakedRequest;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use nostriphant\NIP19\Data; use nostriphant\NIP19\Data;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
@ -30,24 +31,24 @@ class NostrClient
*/ */
private const REPUTABLE_RELAYS = [ private const REPUTABLE_RELAYS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://relay.damus.io', 'wss://nostr.land',
'wss://relay.primal.net', 'wss://purplepag.es',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.snort.social', 'wss://relay.snort.social',
// 'wss://nostr.land', // requires auth that doesn't currently work! 'wss://relay.damus.io',
'wss://purplepag.es', 'wss://relay.primal.net',
]; ];
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
private readonly ArticleFactory $articleFactory, private readonly ArticleFactory $articleFactory,
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger) private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $npubCache)
{ {
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public aggregator relay $this->defaultRelaySet->addRelay(new Relay('wss://aggr.nostr.land')); // aggregator relay, has AUTH
$this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public aggregator relay
} }
/** /**
@ -397,6 +398,17 @@ class NostrClient
*/ */
public function getNpubRelays($npub): array public function getNpubRelays($npub): array
{ {
$cacheKey = 'npub_relays_' . $npub;
try {
$cachedItem = $this->npubCache->getItem($cacheKey);
if ($cachedItem->isHit()) {
$this->logger->debug('Using cached relays for npub', ['npub' => $npub]);
return $cachedItem->get();
}
} catch (\Exception $e) {
$this->logger->warning('Cache error', ['error' => $e->getMessage()]);
}
// Get relays // Get relays
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST], kinds: [KindsEnum::RELAY_LIST],
@ -419,9 +431,21 @@ class NostrClient
} }
} }
// Remove duplicates, localhost and any non-wss relays // Remove duplicates, localhost and any non-wss relays
return array_filter(array_unique($relays), function ($relay) { $relays = array_filter(array_unique($relays), function ($relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
}); });
// Cache the result
try {
$cachedItem = $this->npubCache->getItem($cacheKey);
$cachedItem->set($relays);
$cachedItem->expiresAfter(3600); // 1 hour
$this->npubCache->save($cachedItem);
} catch (\Exception $e) {
$this->logger->warning('Cache save error', ['error' => $e->getMessage()]);
}
return $relays;
} }
/** /**
@ -899,8 +923,14 @@ class NostrClient
} }
} }
$this->logger->info('Relay set for request', ['relays' => $relaySet ? $relaySet->getRelays() : 'default']);
$requestMessage = new RequestMessage($subscription->getId(), [$filter]); $requestMessage = new RequestMessage($subscription->getId(), [$filter]);
return (new TweakedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage))->stopOnEventId($stopGap); return (new TweakedRequest(
$relaySet ?? $this->defaultRelaySet,
$requestMessage,
$this->logger
))->stopOnEventId($stopGap);
} }
private function processResponse(array $response, callable $eventHandler): array private function processResponse(array $response, callable $eventHandler): array
@ -943,7 +973,7 @@ class NostrClient
} }
break; break;
case 'AUTH': case 'AUTH':
$this->logger->warning('Relay requires authentication', [ $this->logger->info('Relay required authentication (handled during request)', [
'relay' => $relayUrl, 'relay' => $relayUrl,
'response' => $item 'response' => $item
]); ]);

32
src/Service/RedisCacheService.php

@ -18,7 +18,7 @@ readonly class RedisCacheService
{ {
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
private CacheItemPoolInterface $redisCache, private CacheItemPoolInterface $appCache,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private LoggerInterface $logger private LoggerInterface $logger
) {} ) {}
@ -50,7 +50,7 @@ readonly class RedisCacheService
$content->name = $defaultName; $content->name = $defaultName;
try { try {
$content = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { $content = $this->appCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed
$rawEvent = $this->fetchRawUserEvent($pubkey); $rawEvent = $this->fetchRawUserEvent($pubkey);
return $this->parseUserMetadata($rawEvent, $pubkey); return $this->parseUserMetadata($rawEvent, $pubkey);
@ -60,9 +60,9 @@ readonly class RedisCacheService
} }
// If content is still default, delete cache to retry next time // If content is still default, delete cache to retry next time
if (isset($content->name) && $content->name === $defaultName if (isset($content->name) && $content->name === $defaultName
&& $this->redisCache->hasItem($cacheKey)) { && $this->appCache->hasItem($cacheKey)) {
try { try {
$this->redisCache->deleteItem($cacheKey); $this->appCache->deleteItem($cacheKey);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error deleting user cache item.', ['exception' => $e]); $this->logger->error('Error deleting user cache item.', ['exception' => $e]);
} }
@ -145,7 +145,7 @@ readonly class RedisCacheService
} }
$cacheKey = '0_with_raw_' . $pubkey; $cacheKey = '0_with_raw_' . $pubkey;
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed
$rawEvent = $this->fetchRawUserEvent($pubkey); $rawEvent = $this->fetchRawUserEvent($pubkey);
$contentData = $this->parseUserMetadata($rawEvent, $pubkey); $contentData = $this->parseUserMetadata($rawEvent, $pubkey);
@ -186,7 +186,7 @@ readonly class RedisCacheService
$result = []; $result = [];
$cacheKeys = array_map(fn($pubkey) => $this->getUserCacheKey($pubkey), $pubkeys); $cacheKeys = array_map(fn($pubkey) => $this->getUserCacheKey($pubkey), $pubkeys);
$pubkeyMap = array_combine($cacheKeys, $pubkeys); $pubkeyMap = array_combine($cacheKeys, $pubkeys);
$items = $this->redisCache->getItems($cacheKeys); $items = $this->appCache->getItems($cacheKeys);
foreach ($items as $cacheKey => $item) { foreach ($items as $cacheKey => $item) {
$pubkey = $pubkeyMap[$cacheKey]; $pubkey = $pubkeyMap[$cacheKey];
if ($item->isHit()) { if ($item->isHit()) {
@ -205,7 +205,7 @@ readonly class RedisCacheService
$cacheKey = '10002_' . $npub; $cacheKey = '10002_' . $npub;
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed
try { try {
$relays = $this->nostrClient->getNpubRelays($npub); $relays = $this->nostrClient->getNpubRelays($npub);
@ -230,7 +230,7 @@ readonly class RedisCacheService
{ {
// redis cache lookup of magazine index by slug // redis cache lookup of magazine index by slug
$key = 'magazine-index-' . $slug; $key = 'magazine-index-' . $slug;
return $this->redisCache->get($key, function (ItemInterface $item) use ($slug) { return $this->appCache->get($key, function (ItemInterface $item) use ($slug) {
$item->expiresAfter(3600); // 1 hour $item->expiresAfter(3600); // 1 hour
$nzines = $this->entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]); $nzines = $this->entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]);
@ -271,9 +271,9 @@ readonly class RedisCacheService
// Insert the new article tag at the top // Insert the new article tag at the top
array_unshift($index->tags, $articleTag); array_unshift($index->tags, $articleTag);
try { try {
$item = $this->redisCache->getItem($key); $item = $this->appCache->getItem($key);
$item->set($index); $item->set($index);
$this->redisCache->save($item); $this->appCache->save($item);
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error updating magazine index.', ['exception' => $e]); $this->logger->error('Error updating magazine index.', ['exception' => $e]);
@ -292,7 +292,7 @@ readonly class RedisCacheService
{ {
$cacheKey = 'media_' . $npub . '_' . $limit; $cacheKey = 'media_' . $npub . '_' . $limit;
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub, $limit) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($npub, $limit) {
$item->expiresAfter(600); // 10 minutes cache for media events $item->expiresAfter(600); // 10 minutes cache for media events
try { try {
@ -340,7 +340,7 @@ readonly class RedisCacheService
try { try {
// Fetch and cache all media events // Fetch and cache all media events
$allMediaEvents = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { $allMediaEvents = $this->appCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
$item->expiresAfter(600); // 10 minutes cache $item->expiresAfter(600); // 10 minutes cache
try { try {
@ -411,7 +411,7 @@ readonly class RedisCacheService
$cacheKey = 'event_' . $eventId . ($relays ? '_' . md5(json_encode($relays)) : ''); $cacheKey = 'event_' . $eventId . ($relays ? '_' . md5(json_encode($relays)) : '');
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($eventId, $relays) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($eventId, $relays) {
$item->expiresAfter(1800); // 30 minutes cache for events $item->expiresAfter(1800); // 30 minutes cache for events
try { try {
@ -439,7 +439,7 @@ readonly class RedisCacheService
$cacheKey = 'naddr_' . $decodedData['kind'] . '_' . $decodedData['pubkey'] . '_' . $decodedData['identifier'] . '_' . md5(json_encode($decodedData['relays'] ?? [])); $cacheKey = 'naddr_' . $decodedData['kind'] . '_' . $decodedData['pubkey'] . '_' . $decodedData['identifier'] . '_' . md5(json_encode($decodedData['relays'] ?? []));
try { try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($decodedData) { return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($decodedData) {
$item->expiresAfter(1800); // 30 minutes cache for naddr events $item->expiresAfter(1800); // 30 minutes cache for naddr events
try { try {
@ -462,10 +462,10 @@ readonly class RedisCacheService
$npub = $key->convertPublicKeyToBech32($event->getPublicKey()); $npub = $key->convertPublicKeyToBech32($event->getPublicKey());
$cacheKey = '0_' . $npub; $cacheKey = '0_' . $npub;
try { try {
$item = $this->redisCache->getItem($cacheKey); $item = $this->appCache->getItem($cacheKey);
$item->set(json_decode($event->getContent())); $item->set(json_decode($event->getContent()));
$item->expiresAfter(3600); // 1 hour $item->expiresAfter(3600); // 1 hour
$this->redisCache->save($item); $this->appCache->save($item);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error setting user metadata.', ['exception' => $e]); $this->logger->error('Error setting user metadata.', ['exception' => $e]);
} }

36
src/Twig/Components/Organisms/Comments.php

@ -2,12 +2,14 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use App\Message\FetchCommentsMessage;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\NostrLinkParser; use App\Service\NostrLinkParser;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent()]
final class Comments final class Comments
{ {
public array $list = []; public array $list = [];
@ -16,13 +18,17 @@ final class Comments
public array $zapAmounts = []; public array $zapAmounts = [];
public array $zappers = []; public array $zappers = [];
public array $authorsMetadata = []; public array $authorsMetadata = [];
public bool $loading = true;
private MessageBusInterface $bus;
public function __construct( public function __construct(
private readonly NostrClient $nostrClient, private readonly NostrClient $nostrClient,
private readonly NostrLinkParser $nostrLinkParser, private readonly NostrLinkParser $nostrLinkParser,
private readonly RedisCacheService $redisCacheService private readonly RedisCacheService $redisCacheService,
MessageBusInterface $bus
) { ) {
$this->bus = $bus;
} }
/** /**
@ -30,25 +36,11 @@ final class Comments
*/ */
public function mount($current): void public function mount($current): void
{ {
// Fetch comments // Instead of fetching comments directly, dispatch async message
$this->list = $this->nostrClient->getComments($current); $this->loading = true;
// sort list by created_at descending $this->list = [];
usort($this->list, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0)); $this->bus->dispatch(new FetchCommentsMessage($current));
// Parse Nostr links in comments but don't fetch previews // The actual comments will be loaded via Mercure on the frontend
$this->parseNostrLinks();
// Parse Zaps to get amounts and zappers from receipts
$this->parseZaps();
// Collect all unique pubkeys for batch metadata fetching
$pubkeys = [];
foreach ($this->list as $comment) {
if ($comment->kind != 9735) {
$pubkeys[] = $comment->pubkey;
} elseif (isset($this->zappers[$comment->id])) {
$pubkeys[] = $this->zappers[$comment->id];
}
}
$pubkeys = array_unique($pubkeys);
$this->authorsMetadata = $this->redisCacheService->getMultipleMetadata($pubkeys);
} }
/** /**

25
src/Util/NostrPhp/TweakedRequest.php

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace App\Util\NostrPhp; namespace App\Util\NostrPhp;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\AuthMessage; use swentel\nostr\Message\AuthMessage;
use swentel\nostr\Message\CloseMessage; use swentel\nostr\Message\CloseMessage;
use swentel\nostr\MessageInterface; use swentel\nostr\MessageInterface;
@ -22,6 +24,7 @@ use WebSocket\Message\Text;
*/ */
final class TweakedRequest implements RequestInterface final class TweakedRequest implements RequestInterface
{ {
private $nsec;
private RelaySet $relays; private RelaySet $relays;
private string $payload; private string $payload;
private array $responses = []; private array $responses = [];
@ -29,7 +32,7 @@ final class TweakedRequest implements RequestInterface
/** Optional: when set, CLOSE & disconnect immediately once this id arrives */ /** Optional: when set, CLOSE & disconnect immediately once this id arrives */
private ?string $stopOnEventId = null; private ?string $stopOnEventId = null;
public function __construct(Relay|RelaySet $relay, MessageInterface $message) public function __construct(Relay|RelaySet $relay, MessageInterface $message, private readonly LoggerInterface $logger)
{ {
if ($relay instanceof RelaySet) { if ($relay instanceof RelaySet) {
$this->relays = $relay; $this->relays = $relay;
@ -39,6 +42,10 @@ final class TweakedRequest implements RequestInterface
$this->relays = $set; $this->relays = $set;
} }
$this->payload = $message->generate(); $this->payload = $message->generate();
// Create an ephemeral key for NIP-42 auth
$key = new Key();
$this->nsec = $key->generatePrivateKey();
} }
public function stopOnEventId(?string $hexId): self public function stopOnEventId(?string $hexId): self
@ -51,7 +58,6 @@ final class TweakedRequest implements RequestInterface
public function send(): array public function send(): array
{ {
$result = []; $result = [];
foreach ($this->relays->getRelays() as $relay) { foreach ($this->relays->getRelays() as $relay) {
$this->responses = []; // reset per relay $this->responses = []; // reset per relay
try { try {
@ -125,6 +131,15 @@ final class TweakedRequest implements RequestInterface
$client->text($this->payload); $client->text($this->payload);
// continue loop // continue loop
} }
// NIP-42: handle AUTH challenge for subscriptions
if ($relayResponse->type === 'AUTH') {
$raw = json_decode($resp->getContent(), true);
$_SESSION['challenge'] = $raw[1] ?? '';
$this->logger->warning('Received AUTH challenge from relay: ' . $relay->getUrl());
$this->performAuth($relay, $client);
// continue loop, relay should now respond to the subscription
}
} }
// Save what we got for this relay // Save what we got for this relay
@ -150,16 +165,14 @@ final class TweakedRequest implements RequestInterface
/** Very lightweight NIP-42 auth flow: sign challenge and send AUTH + resume. */ /** Very lightweight NIP-42 auth flow: sign challenge and send AUTH + resume. */
private function performAuth(Relay $relay, WsClient $client): void private function performAuth(Relay $relay, WsClient $client): void
{ {
// NOTE: This reuses the vendor types, but uses a dummy secret. You should inject your real sec key.
if (!isset($_SESSION['challenge'])) { if (!isset($_SESSION['challenge'])) {
return; return;
} }
try { try {
$authEvent = new AuthEvent($relay->getUrl(), $_SESSION['challenge']); $authEvent = new AuthEvent($relay->getUrl(), $_SESSION['challenge']);
$sec = '0000000000000000000000000000000000000000000000000000000000000001'; // TODO inject your real sec (new Sign())->signEvent($authEvent, $this->nsec);
(new Sign())->signEvent($authEvent, $sec);
$authMsg = new AuthMessage($authEvent); $authMsg = new AuthMessage($authEvent);
$this->logger->warning('Sending NIP-42 AUTH to relay: ' . $relay->getUrl());
$client->text($authMsg->generate()); $client->text($authMsg->generate());
} catch (\Throwable) { } catch (\Throwable) {
// ignore and continue; some relays won’t require it // ignore and continue; some relays won’t require it

12
symfony.lock

@ -101,6 +101,18 @@
".env.dev" ".env.dev"
] ]
}, },
"symfony/form": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": { "symfony/framework-bundle": {
"version": "7.1", "version": "7.1",
"recipe": { "recipe": {

1
templates/base.html.twig

@ -23,6 +23,7 @@
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
<meta name="mercure-hub" content="{{ mercure_public_hub_url }}" />
</head> </head>
<body data-controller="service-worker visit-analytics" data-visit-analytics-path-value="{{ app.request.pathInfo }}"> <body data-controller="service-worker visit-analytics" data-visit-analytics-path-value="{{ app.request.pathInfo }}">

33
templates/components/Organisms/Comments.html.twig

@ -1,4 +1,13 @@
<div class="comments"> <div class="comments"
data-controller="comments-mercure"
data-comments-mercure-coordinate-value="{{ current }}"
data-comments-mercure-target="root"
id="comments-{{ current|e('html_attr') }}"
data-comments-coordinate="{{ current }}">
{% if loading %}
<div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div>
{% endif %}
<div class="comments-list" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}>
{% for item in list %} {% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}"> <div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata"> <div class="metadata">
@ -25,7 +34,6 @@
<twig:Atoms:Content content="{{ item.content }}" /> <twig:Atoms:Content content="{{ item.content }}" />
</div> </div>
{# Display Nostr link previews if links detected #} {# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %} {% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
@ -38,24 +46,9 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# Display tags if user logged in and has role ROLE_ADMIN #}
{% if is_granted('ROLE_ADMIN') %}
{% if item.tags is defined and item.tags|length > 0 %}
<ul>
{% for tag in item.tags %}
<li>
<strong>{{ tag[0] }}:</strong> {{ tag[1] }}
{% if tag[2] is defined %}
<span>{{ tag[2] }}</span>
{% endif %}
{% if tag[3] is defined %}
<span>{{ tag[3] }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div> </div>
{% else %}
<div class="no-comments">No comments yet.</div>
{% endfor %} {% endfor %}
</div>
</div> </div>

Loading…
Cancel
Save