Browse Source

refactor caching/DB

only replies in cache, everything else DB
imwald
Silberengel 4 days ago
parent
commit
f969bc4a57
  1. 6
      assets/controllers/progress_bar_controller.js
  2. 6
      config/packages/cache.yaml
  3. 12
      config/services.yaml
  4. 31
      migrations/Version20260424130000.php
  5. 4
      src/Command/PrewarmCommand.php
  6. 13
      src/Controller/ArticleController.php
  7. 41
      src/Entity/Event.php
  8. 62
      src/Nostr/MagazineEventKeys.php
  9. 25
      src/Repository/EventRepository.php
  10. 328
      src/Service/CacheService.php
  11. 126
      src/Service/MagazineIndexStore.php
  12. 191
      src/Service/Nip09DeletionApplier.php
  13. 145
      src/Service/NostrClient.php

6
assets/controllers/progress_bar_controller.js

@ -7,8 +7,10 @@ export default class extends Controller { @@ -7,8 +7,10 @@ export default class extends Controller {
static targets = ['bar'];
connect() {
this.boundHandleInteraction = this.handleInteraction.bind(this);
this.boundPageShow = this.onPageShow.bind(this);
// Bind once per controller instance so reconnects match disconnect()'s
// removeEventListener; new .bind() references each connect() would leave stale listeners.
this.boundHandleInteraction ??= this.handleInteraction.bind(this);
this.boundPageShow ??= this.onPageShow.bind(this);
document.addEventListener('click', this.boundHandleInteraction);
document.addEventListener('touchstart', this.handleTouchStart);
document.addEventListener('touchend', this.handleTouchEnd);

6
config/packages/cache.yaml

@ -10,3 +10,9 @@ framework: @@ -10,3 +10,9 @@ framework:
pools:
#my.dedicated.cache: null
subscriptions.cache: null
# Comment / reply / trackback UI only (not profile, index, or article body).
cache.replies:
adapter: cache.adapter.filesystem
# Unpublished editor preview payloads only.
cache.drafts:
adapter: cache.adapter.filesystem

12
config/services.yaml

@ -40,7 +40,7 @@ services: @@ -40,7 +40,7 @@ services:
$projectDir: '%kernel.project_dir%'
App\Service\ArticleCommentThreadLoader:
arguments:
$appCachePool: '@cache.app'
$appCachePool: '@cache.replies'
App\Twig\FooterLinksExtension:
arguments:
$footerLinksPath: '%footer_links%'
@ -49,18 +49,14 @@ services: @@ -49,18 +49,14 @@ services:
tags: [ 'twig.extension' ]
App\Twig\MagazineJumbleExtension:
tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore:
arguments:
$pool: '@cache.app'
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'
$magazinePrewarmPreferSlugs: '%magazine_prewarm_prefer_slugs%'
$magazinePrewarmAlsoSlugs: '%magazine_prewarm_also_slugs%'
App\Service\CacheService:
arguments:
$appCache: '@cache.app'
App\Controller\ArticleController:
bind:
$articlesCache: '@cache.drafts'
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'

31
migrations/Version20260424130000.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Core Nostr events (30040 indices, kind-0 profiles) live in `event` with a stable {@see Event::getCoreRowKey()}.
*/
final class Version20260424130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'event.core_row_key + event.storage_role for DB-backed magazine indices and profiles';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE event ADD core_row_key VARCHAR(255) DEFAULT NULL, ADD storage_role VARCHAR(32) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_3BAE0AA7F6F0AF27 ON event (core_row_key)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_3BAE0AA7F6F0AF27 ON event');
$this->addSql('ALTER TABLE event DROP core_row_key, DROP storage_role');
}
}

4
src/Command/PrewarmCommand.php

@ -319,8 +319,8 @@ final class PrewarmCommand extends Command @@ -319,8 +319,8 @@ final class PrewarmCommand extends Command
$bar->start();
try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0MetadataForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched, $keys);
$fetched = $this->nostrClient->fetchKind0WireEventsForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched);
$bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? '');
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…');

13
src/Controller/ArticleController.php

@ -295,7 +295,6 @@ class ArticleController extends AbstractController @@ -295,7 +295,6 @@ class ArticleController extends AbstractController
string $slug,
EntityManagerInterface $entityManager,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response
@ -312,7 +311,6 @@ class ArticleController extends AbstractController @@ -312,7 +311,6 @@ class ArticleController extends AbstractController
return $this->renderArticle(
$article,
$cacheService,
$articlesCache,
$converter,
$commentThreadLoader
);
@ -363,19 +361,13 @@ class ArticleController extends AbstractController @@ -363,19 +361,13 @@ class ArticleController extends AbstractController
private function renderArticle(
Article $article,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response {
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
$cacheKey = 'article_'.$article->getId();
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent()));
$articlesCache->save($cacheItem);
}
$html = $converter->convertToHtml($article->getContent());
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
@ -406,7 +398,7 @@ class ArticleController extends AbstractController @@ -406,7 +398,7 @@ class ArticleController extends AbstractController
'article' => $article,
'author' => $author,
'npub' => $npub,
'content' => $cacheItem->get(),
'content' => $html,
'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded,
]);
@ -421,7 +413,6 @@ class ArticleController extends AbstractController @@ -421,7 +413,6 @@ class ArticleController extends AbstractController
Request $request,
NostrClient $nostrClient,
CacheService $cacheService,
CacheItemPoolInterface $articlesCache
): Response {
$data = $request->getContent();
$descriptor = json_decode($data);

41
src/Entity/Event.php

@ -2,15 +2,27 @@ @@ -2,15 +2,27 @@
namespace App\Entity;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Nostr events
* Nostr events stored in MySQL (kind-0 profiles, 30040 indices, kind-3 relay lists, etc.).
* Ephemeral reply/comment UI data must not use this table.
*/
#[ORM\Entity]
#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
public const STORAGE_MAGAZINE_ROOT = 'magazine_root';
public const STORAGE_MAGAZINE_CATEGORY = 'magazine_category';
public const STORAGE_PROFILE_KIND0 = 'profile';
public const STORAGE_RELAY_LIST_10002 = 'relay_list';
public const STORAGE_PAYTO_10133 = 'payto_10133';
#[ORM\Id]
#[ORM\Column(length: 225)]
private string $id;
@ -29,6 +41,12 @@ class Event @@ -29,6 +41,12 @@ class Event
#[ORM\Column(length: 255)]
private string $sig = '';
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $coreRowKey = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $storageRole = null;
public function getId(): string
{
return $this->id;
@ -111,6 +129,25 @@ class Event @@ -111,6 +129,25 @@ class Event
$this->sig = $sig;
}
public function getCoreRowKey(): ?string
{
return $this->coreRowKey;
}
public function setCoreRowKey(?string $coreRowKey): void
{
$this->coreRowKey = $coreRowKey;
}
public function getStorageRole(): ?string
{
return $this->storageRole;
}
public function setStorageRole(?string $storageRole): void
{
$this->storageRole = $storageRole;
}
public function getTitle(): ?string
{

62
src/Nostr/MagazineEventKeys.php

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use swentel\nostr\Key\Key;
/**
* Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL.
*/
final class MagazineEventKeys
{
public static function magazineRoot(string $npub, string $rootDTag): string
{
$hex = self::npubToHex($npub);
if ($hex === '') {
return '';
}
return 'mr:'.$hex.':'.trim($rootDTag, " \0\x0B\t\n\r");
}
public static function magazineCategory(string $categoryDTag): string
{
return 'mc:'.trim($categoryDTag, " \0\x0B\t\n\r");
}
public static function profileKind0(string $authorPubkeyHex64): string
{
return 'pr:'.strtolower($authorPubkeyHex64);
}
public static function relayList10002(string $authorPubkeyHex64): string
{
return 'k10002:'.strtolower($authorPubkeyHex64);
}
/**
* NIP-33 + NIP-A3: kind 10133, pubkey hex, d-tag from the address.
*/
public static function payto10133(string $authorPubkeyHex64, string $dTag): string
{
$d = trim($dTag, " \0\x0B\t\n\r");
return 'k10133:'.strtolower($authorPubkeyHex64).':'.$d;
}
private static function npubToHex(string $npub): string
{
if (64 === \strlen($npub) && ctype_xdigit($npub)) {
return strtolower($npub);
}
try {
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}
return (\is_string($h) && 64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : '';
}
}

25
src/Repository/EventRepository.php

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Event;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Event>
*/
class EventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Event::class);
}
public function findOneByCoreRowKey(string $key): ?Event
{
return $this->findOneBy(['coreRowKey' => $key]);
}
}

328
src/Service/CacheService.php

@ -1,98 +1,117 @@ @@ -1,98 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Entity\Event;
use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService
{
public function __construct(
private NostrClient $nostrClient,
private CacheInterface $cache,
private LoggerInterface $logger,
private CacheItemPoolInterface $appCache,
private NostrClient $nostrClient,
private EntityManagerInterface $entityManager,
private EventRepository $eventRepository,
private LoggerInterface $logger,
) {
}
/**
* @param string $npub
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass
{
return $this->getMetadataBundle($npub)['content'];
}
/**
* Kind-0 content JSON, tags (for payto/website/nip05), and any relay round trip once per cache item.
*
* @return array{content: \stdClass, kind0_tags: list<list<string>>}
*/
public function getMetadataBundle(string $npub): array
{
// One key per author: do not split on Nostr.Land / aggr (see comment thread cache). Otherwise
// prewarm and anonymous hits do not match logged-in readers → cold Nostr on every article view.
$cacheKey = '0_'.$npub;
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null) {
return $this->placeholderMetadataBundle($npub);
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex));
if ($row !== null) {
return $this->bundleFromKind0EventRow($row, $npub);
}
try {
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$ev = $this->nostrClient->getNpubMetadata($npub);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
try {
$data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
$data = new \stdClass();
}
if (!\is_object($data)) {
$data = new \stdClass();
}
return [
'content' => $data,
'kind0_tags' => $tags,
];
} catch (\Exception $e) {
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e);
}
});
if (\is_array($cached) && isset($cached['content']) && $cached['content'] instanceof \stdClass) {
return [
'content' => $cached['content'],
'kind0_tags' => \is_array($cached['kind0_tags'] ?? null) ? $cached['kind0_tags'] : [],
];
$ev = $this->nostrClient->getNpubMetadata($npub);
if (!\is_object($ev)) {
return $this->placeholderMetadataBundle($npub);
}
// Legacy: cache stored only the decoded content object
if ($cached instanceof \stdClass) {
return ['content' => $cached, 'kind0_tags' => []];
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($authorHex),
Event::STORAGE_PROFILE_KIND0,
$ev
);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
$content = $this->decodeKind0ContentObject($ev);
if ($this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
}
} catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e;
return ['content' => $content, 'kind0_tags' => $tags];
} catch (\Exception $e) {
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
'exception' => $root,
'exception' => $e->getPrevious() ?? $e,
]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
}
return $this->placeholderMetadataBundle($npub);
}
return [
'content' => $content,
'kind0_tags' => [],
];
/**
* Prewarm: batch upsert of kind-0 profile rows in {@see Event}.
*
* @param list<string> $authorPubkeyHex
* @param array<string, object> $wireByLowerHex from {@see NostrClient::fetchKind0WireEventsForAuthors} (keys are lowercase 64-hex)
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $wireByLowerHex): int
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$h = strtolower($hex);
if (!isset($wireByLowerHex[$h]) || !\is_object($wireByLowerHex[$h])) {
continue;
}
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($h),
Event::STORAGE_PROFILE_KIND0,
$wireByLowerHex[$h]
);
++$n;
}
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $n;
}
return [
'content' => $content,
'kind0_tags' => [],
];
public function getRelays($npub)
{
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null) {
return [];
}
$key = MagazineEventKeys::relayList10002($authorHex);
$row = $this->eventRepository->findOneByCoreRowKey($key);
if ($row !== null) {
return self::relayWssListFromNip65Tags($row->getTags());
}
$wire = $this->nostrClient->getNpubRelayList10002Wire($npub);
if ($wire === null) {
return [];
}
$this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire);
return NostrClient::relayWssListFromNip65Object($wire);
}
/**
@ -126,75 +145,162 @@ readonly class CacheService @@ -126,75 +145,162 @@ readonly class CacheService
return $out;
}
/**
* @param list<string> $authorPubkeyHex
* @param array<string, \stdClass> $metadataByHex from {@see NostrClient::fetchKind0MetadataForAuthors}
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $metadataByHex, Key $key): int
private function npubToAuthorHex64(string $npub): ?string
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
if (strlen($hex) !== 64) {
continue;
if (64 === \strlen($npub) && ctype_xdigit($npub)) {
return strtolower($npub);
}
if (str_starts_with($npub, 'npub1')) {
try {
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}
$npub = $key->convertPublicKeyToBech32($hex);
if (isset($metadataByHex[$hex]) && $metadataByHex[$hex] instanceof \stdClass) {
$this->putProfileInCache($npub, $metadataByHex[$hex]);
} else {
$this->putProfilePlaceholderInCache($npub);
if (64 === \strlen((string) $h) && ctype_xdigit((string) $h)) {
return strtolower($h);
}
++$n;
}
return $n;
return null;
}
public function getRelays($npub)
private function replaceByCoreKey(string $coreKey, string $storageRole, object $rawWire): void
{
$cacheKey = '3_' . $npub;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour
try {
return $this->nostrClient->getNpubRelays($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting relays.', ['exception' => $e]);
return [];
}
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting relay data.', ['exception' => $e]);
return [];
$entity = $this->wireToEventEntity($rawWire);
if ($entity === null) {
return;
}
$entity->setCoreRowKey($coreKey);
$entity->setStorageRole($storageRole);
if ($entity->getEventId() === null) {
$entity->setEventId($entity->getId());
}
$prev = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($prev !== null && $prev->getId() === $entity->getId()) {
$prev->setKind($entity->getKind());
$prev->setPubkey($entity->getPubkey());
$prev->setContent($entity->getContent());
$prev->setCreatedAt($entity->getCreatedAt());
$prev->setTags($entity->getTags());
$prev->setSig($entity->getSig());
$prev->setCoreRowKey($coreKey);
$prev->setStorageRole($storageRole);
if ($entity->getEventId() !== null) {
$prev->setEventId($entity->getEventId());
}
$this->entityManager->flush();
return;
}
if ($prev !== null) {
$this->entityManager->remove($prev);
$this->entityManager->flush();
}
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
private function putProfileInCache(string $npub, \stdClass $content): void
private function wireToEventEntity(object $raw): ?Event
{
try {
$item = $this->appCache->getItem('0_'.$npub);
$item->set($content);
$item->expiresAfter(3600);
$this->appCache->save($item);
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfileInCache', ['npub' => $npub, 'exception' => $e]);
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
$id = (string) ($data['id'] ?? '');
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
return null;
}
$e = new Event();
$e->setId(strtolower($id));
$e->setEventId(strtolower($id));
$e->setKind((int) ($data['kind'] ?? 0));
$e->setPubkey(strtolower((string) ($data['pubkey'] ?? '')));
$e->setContent((string) ($data['content'] ?? ''));
$e->setCreatedAt((int) ($data['created_at'] ?? 0));
$tags = $data['tags'] ?? [];
$e->setTags(\is_array($tags) ? $tags : []);
$e->setSig((string) ($data['sig'] ?? ''));
return $e;
}
private function putProfilePlaceholderInCache(string $npub): void
private function bundleFromKind0EventRow(Event $row, string $npub): array
{
try {
$item = $this->appCache->getItem('0_'.$npub);
if ($item->isHit()) {
// Prewarm miss: keep an earlier good (or any) value — do not downgrade to placeholder.
return;
}
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfilePlaceholderInCache', ['npub' => $npub, 'exception' => $e]);
$content = $this->decodeKind0ContentString($row->getContent());
if (!\is_object($content) || $this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
}
return;
return [
'content' => $content,
'kind0_tags' => self::normalizeEventTagsList($row->getTags()),
];
}
private function decodeKind0ContentObject(object $ev): \stdClass
{
return $this->decodeKind0ContentString((string) ($ev->content ?? ''));
}
private function decodeKind0ContentString(string $raw): \stdClass
{
try {
$data = \json_decode($raw, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return new \stdClass();
}
if (!\is_object($data)) {
return new \stdClass();
}
return $data;
}
private function isPlaceholderContent(\stdClass $content, string $npub): bool
{
$n = (string) ($content->name ?? '');
return $n === substr($npub, 0, 8).'…'.substr($npub, -4);
}
private function namePlaceholderNpubObject(string $npub): \stdClass
{
$c = new \stdClass();
$c->name = substr($npub, 0, 8).'…'.substr($npub, -4);
$this->putProfileInCache($npub, $c);
return $c;
}
private function placeholderMetadataBundle(string $npub): array
{
return [
'content' => $this->namePlaceholderNpubObject($npub),
'kind0_tags' => [],
];
}
/**
* @param list<list<string>>|array $tags
* @return list<string>
*/
private static function relayWssListFromNip65Tags(array $tags): array
{
$relays = [];
foreach ($tags as $tag) {
if (!\is_array($tag) || !isset($tag[0], $tag[1])) {
continue;
}
if ((string) $tag[0] === 'r') {
$relays[] = (string) $tag[1];
}
}
return array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
}
}

126
src/Service/MagazineIndexStore.php

@ -5,34 +5,33 @@ declare(strict_types=1); @@ -5,34 +5,33 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
* on the request path. Updated by {@see MagazineRefresher} (via `app:prewarm` / cron, or explicit CLI use).
* Magazine Nostr index events (kind 30040) in MySQL {@see Event}. Updated by {@see MagazineRefresher}
* (`app:prewarm` / cron).
*/
final class MagazineIndexStore
{
private const ROOT_PREFIX = 'mroot_v1_';
private const CAT_PREFIX = 'mcat_v1_';
/** 30 days — we refresh on page load, TTL is a safety cap if sync stops working. */
private const PERSIST_TTL = 2_592_000;
public function __construct(
private readonly CacheItemPoolInterface $pool,
private readonly EntityManagerInterface $entityManager,
private readonly EventRepository $eventRepository,
) {
}
public function getRoot(string $npub, string $dTag): ?Event
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
if (!$item->isHit()) {
if ($dTag === '') {
return null;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
if ($key === '') {
return null;
}
return $this->unwrap($item->get());
return $this->eventRepository->findOneByCoreRowKey($key);
}
public function getCategory(string $slug): ?Event
@ -40,85 +39,86 @@ final class MagazineIndexStore @@ -40,85 +39,86 @@ final class MagazineIndexStore
if ($slug === '') {
return null;
}
$item = $this->pool->getItem($this->categoryKey($slug));
if (!$item->isHit()) {
return null;
}
$key = MagazineEventKeys::magazineCategory($slug);
return $this->unwrap($item->get());
return $this->eventRepository->findOneByCoreRowKey($key);
}
/**
* @throws InvalidArgumentException
*/
public function putRoot(string $npub, string $dTag, Event $event): void
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
if ($key === '') {
return;
}
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_ROOT, $event);
}
/**
* @throws InvalidArgumentException
*/
public function putCategory(string $slug, Event $event): void
{
if ($slug === '') {
return;
}
$item = $this->pool->getItem($this->categoryKey($slug));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
$key = MagazineEventKeys::magazineCategory($slug);
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event);
}
/**
* Remove a cached category index (NIP-09 / local invalidation).
*
* @throws InvalidArgumentException
*/
public function deleteCategory(string $slug): void
{
if ($slug === '') {
return;
}
$this->pool->deleteItem($this->categoryKey($slug));
$key = MagazineEventKeys::magazineCategory($slug);
$this->removeByCoreKey($key);
}
/**
* Remove the cached root magazine index for this npub + d_tag.
*
* @throws InvalidArgumentException
*/
public function deleteRoot(string $npub, string $dTag): void
{
$this->pool->deleteItem($this->rootKey($npub, $dTag));
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
$this->removeByCoreKey($key);
}
private function rootKey(string $npub, string $dTag): string
private function replaceByCoreKey(string $coreKey, string $role, Event $incoming): void
{
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
}
$prev = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($prev !== null && $prev->getId() === $incoming->getId()) {
$prev->setKind($incoming->getKind());
$prev->setPubkey($incoming->getPubkey());
$prev->setContent($incoming->getContent());
$prev->setCreatedAt($incoming->getCreatedAt());
$prev->setTags($incoming->getTags());
$prev->setSig($incoming->getSig());
$prev->setCoreRowKey($coreKey);
$prev->setStorageRole($role);
if ($incoming->getEventId() !== null) {
$prev->setEventId($incoming->getEventId());
}
$this->entityManager->flush();
/**
* Category `d` / slug strings may contain colons (NIP-33 `a` segments); PSR-6 keys must not use `{}()/\@:`.
*/
private function categoryKey(string $slug): string
{
return self::CAT_PREFIX.hash('sha256', $slug);
return;
}
if ($prev !== null) {
$this->entityManager->remove($prev);
$this->entityManager->flush();
}
$incoming->setCoreRowKey($coreKey);
$incoming->setStorageRole($role);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
}
private function unwrap(mixed $value): ?Event
private function removeByCoreKey(string $coreKey): void
{
if (!\is_string($value) || $value === '') {
return null;
}
$e = unserialize($value, ['allowed_classes' => [Event::class]]);
if (!$e instanceof Event) {
return null;
$e = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($e === null) {
return;
}
return $e;
$this->entityManager->remove($e);
$this->entityManager->flush();
}
}

191
src/Service/Nip09DeletionApplier.php

@ -6,7 +6,9 @@ namespace App\Service; @@ -6,7 +6,9 @@ namespace App\Service;
use App\Entity\Event as MagazineNostrEvent;
use App\Enum\KindsEnum;
use App\Nostr\MagazineEventKeys;
use App\Repository\ArticleRepository;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
@ -15,14 +17,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -15,14 +17,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Applies NIP-09 (kind 5) deletion requests to:
* - MySQL: long-form articles ({@see KindsEnum::LONGFORM} 30023, {@see KindsEnum::LONGFORM_DRAFT} 30024)
* - Magazine cache: publication indices ({@see KindsEnum::PUBLICATION_INDEX} 30040) in {@see MagazineIndexStore}
* - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), kind 0 profile, 10002 relay list, 10133 payto
*
* Both are handled for `e` tags (with `k` when present) and for NIP-33 `a` tags.
* Handled for `e` tags (with `k` when present) and for NIP-33 `a` tags.
*
* Relays are not authoritative; we only remove data we can validate (same pubkey as deletion request).
* For cached 30040 category indices (keyed by `d` only), we require the stored event’s author
* to match the deletion — not just an `a` tag whose own pubkey matches, so colliding `d` values
* across authors cannot wipe another author’s cache entry.
* For category 30040 rows (keyed by `d` only), we require the stored event’s author to match the
* deletion author so colliding `d` values across authors cannot wipe another author’s index.
*/
final class Nip09DeletionApplier
{
@ -30,6 +31,7 @@ final class Nip09DeletionApplier @@ -30,6 +31,7 @@ final class Nip09DeletionApplier
private readonly EntityManagerInterface $entityManager,
private readonly ArticleRepository $articleRepository,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly EventRepository $eventRepository,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
@ -73,9 +75,11 @@ final class Nip09DeletionApplier @@ -73,9 +75,11 @@ final class Nip09DeletionApplier
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
1, // NIP-09 may include kind 1; we do not store notes, but must not treat k as “unknown”
], true)) {
// Other kinds: we do not mirror in this app; skip.
continue;
}
if ($declared === 1) {
@ -86,7 +90,9 @@ final class Nip09DeletionApplier @@ -86,7 +90,9 @@ final class Nip09DeletionApplier
++$articlesPendingFlush;
continue;
}
// No DB row: try kind 30040 magazine index by event id; also 30023/24 if not mirrored in DB.
if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) {
continue;
}
if ($declared === null || \in_array($declared, [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
@ -110,12 +116,10 @@ final class Nip09DeletionApplier @@ -110,12 +116,10 @@ final class Nip09DeletionApplier
}
}
if ($articlesPendingFlush > 0) {
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
return [
@ -125,53 +129,86 @@ final class Nip09DeletionApplier @@ -125,53 +129,86 @@ final class Nip09DeletionApplier
];
}
/** 0 = none, 1 = root cache, 2 = category cache */
/**
* Kind 0 / 10002 / 10133 rows in {@see Event} (profile, relay list, payto), by Nostr event id.
*/
private function tryRemoveCoreEventRowByEventId(string $eventId, string $deletionPubkey, ?int $declared): bool
{
$eid = strtolower($eventId);
$e = $this->eventRepository->find($eid);
if ($e === null) {
return false;
}
if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) {
return false;
}
$k = (int) $e->getKind();
if ($declared !== null && $declared !== $k) {
return false;
}
if (!\in_array($k, [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
], true)) {
return false;
}
if ($k === KindsEnum::METADATA->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_PROFILE_KIND0) {
return false;
}
} elseif ($k === KindsEnum::RELAY_LIST->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_RELAY_LIST_10002) {
return false;
}
} elseif ($k === KindsEnum::PAYMENT_TARGETS->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_PAYTO_10133) {
return false;
}
}
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed core event row', [
'event_id' => $eid,
'kind' => $k,
]);
return true;
}
/** 0 = none, 1 = root row, 2 = category row */
private function tryRemoveMagazine30040ByEventId(string $eventId, string $deletionPubkey): int
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($npub === '' || $dTag === '') {
$eid = strtolower($eventId);
$e = $this->eventRepository->find($eid);
if ($e === null) {
return 0;
}
if ((int) $e->getKind() !== KindsEnum::PUBLICATION_INDEX->value) {
return 0;
}
$root = $this->magazineIndexStore->getRoot($npub, $dTag);
if ($root === null) {
if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) {
return 0;
}
if ($this->eventIdMatches($root, $eventId) && $this->pubkeyEquals($root->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteRoot($npub, $dTag);
$this->logger->notice('NIP-09: removed cached magazine root index', [
'event_id' => $eventId,
if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_ROOT) {
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed magazine root index (event table)', [
'event_id' => $eid,
]);
return 1;
}
foreach ($this->categorySlugsFromRoot($root) as $slug) {
$cat = $this->magazineIndexStore->getCategory($slug);
if ($cat === null) {
continue;
}
if ($this->eventIdMatches($cat, $eventId) && $this->pubkeyEquals($cat->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteCategory($slug);
$this->logger->notice('NIP-09: removed cached magazine category index', [
'event_id' => $eventId,
'slug' => $slug,
]);
return 2;
}
if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_CATEGORY) {
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed magazine category index (event table)', [
'event_id' => $eid,
]);
return 2;
}
return 0;
}
private function eventIdMatches(MagazineNostrEvent $e, string $eventId): bool
{
$a = strtolower($e->getId());
$b = strtolower($eventId);
return $a === $b;
}
private function pubkeyEquals(string $a, string $b): bool
{
if (64 !== \strlen($a) || 64 !== \strlen($b)) {
@ -181,29 +218,6 @@ final class Nip09DeletionApplier @@ -181,29 +218,6 @@ final class Nip09DeletionApplier
return strtolower($a) === strtolower($b);
}
/**
* @return list<string>
*/
private function categorySlugsFromRoot(MagazineNostrEvent $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/**
* @param array<string, true> $seenArticleIds
*/
@ -261,11 +275,50 @@ final class Nip09DeletionApplier @@ -261,11 +275,50 @@ final class Nip09DeletionApplier
$kind = (int) $parts[0];
$pk = (string) $parts[1];
$d = trim((string) $parts[2]);
if ($d === '' || !$this->pubkeyEquals($pk, $deletionPubkey)) {
if (!$this->pubkeyEquals($pk, $deletionPubkey)) {
return $out;
}
if ($kind === KindsEnum::METADATA->value) {
if ($d !== '' && $d !== '0') {
return $out;
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0(strtolower($pk)));
if ($row !== null && (int) $row->getKind() === KindsEnum::METADATA->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed profile row (a tag)', ['address' => $addr]);
}
return $out;
}
if ($kind === KindsEnum::RELAY_LIST->value) {
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::relayList10002(strtolower($pk)));
if ($row !== null && (int) $row->getKind() === KindsEnum::RELAY_LIST->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed relay list row (a tag)', ['address' => $addr]);
}
return $out;
}
if ($kind === KindsEnum::PAYMENT_TARGETS->value) {
if ($d === '') {
return $out;
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::payto10133(strtolower($pk), $d));
if ($row !== null && (int) $row->getKind() === KindsEnum::PAYMENT_TARGETS->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed payto 10133 row (a tag)', ['address' => $addr]);
}
return $out;
}
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
if ($d === '') {
return $out;
}
$article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]);
if ($article !== null) {
$eid = (string) ($article->getEventId() ?? '');

145
src/Service/NostrClient.php

@ -646,6 +646,50 @@ class NostrClient @@ -646,6 +646,50 @@ class NostrClient
return $byPub;
}
/**
* Batched kind-0 fetch: one REQ per chunk; returns latest wire event per author (for DB persistence).
*
* @param list<string> $authorPubkeyHex
* @return array<string, object> Keyed by lowercase 64-hex pubkey
*/
public function fetchKind0WireEventsForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaySet = $this->relaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $chunk) {
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
foreach (self::mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) {
if (!\is_object($ev)) {
continue;
}
$pk = \substr((string) $addr, 2);
if (64 === \strlen($pk) && ctype_xdigit($pk)) {
$byPub[strtolower($pk)] = $ev;
}
}
}
return $byPub;
}
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
@ -697,6 +741,9 @@ class NostrClient @@ -697,6 +741,9 @@ class NostrClient
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
if (!self::kind5DeletionRelevantToStoredDbData($ev)) {
continue;
}
$id = (string) ($ev->id ?? '');
if (64 !== \strlen($id)) {
continue;
@ -712,6 +759,45 @@ class NostrClient @@ -712,6 +759,45 @@ class NostrClient
return array_values($byId);
}
/**
* Keep only kind-5 events that (claim to) delete kinds we keep in MySQL: profile, relay list, payto,
* long-form, magazine index. Omits thread/reply/comment deletions to shrink relay responses.
*/
private static function kind5DeletionRelevantToStoredDbData(object $ev): bool
{
static $kinds;
if ($kinds === null) {
$kinds = [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
];
}
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'k' && \in_array((int) $r[1], $kinds, true)) {
return true;
}
if ((string) $r[0] === 'a') {
$parts = explode(':', (string) $r[1], 3);
if ($parts !== [] && \in_array((int) $parts[0], $kinds, true)) {
return true;
}
}
}
return false;
}
/**
* @throws \Exception
*/
@ -1067,44 +1153,69 @@ class NostrClient @@ -1067,44 +1153,69 @@ class NostrClient
}
/**
* @throws \Exception
* Merged NIP-65 (kind 10002) event for the author, or null.
*/
public function getNpubRelays($npub): array
public function getNpubRelayList10002Wire($npub): ?object
{
// Get relays
$request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
);
$response = $this->processResponse($request->send(), function($received) {
$response = $this->processResponse($request->send(), function ($received) {
return $received;
});
if (empty($response)) {
return [];
return null;
}
$merged = self::mergeNip33ParameterizedWireEvents($response);
$use = null;
$k10002 = (int) KindsEnum::RELAY_LIST->value;
foreach ($merged as $e) {
if (\is_object($e) && (int) ($e->kind ?? 0) === $k10002) {
$use = $e;
break;
return $e;
}
}
if ($use === null) {
return [];
}
return null;
}
/**
* NIP-65: `r` values as wss URLs, excluding localhost.
*
* @return list<string>
*/
public static function relayWssListFromNip65Object(object $wire): array
{
$relays = [];
foreach ($use->tags ?? [] as $tag) {
if ($tag[0] === 'r') {
$relays[] = $tag[1];
foreach ($wire->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'r') {
$relays[] = (string) $r[1];
}
}
// Remove duplicates, localhost and any non-wss relays
return array_filter(array_unique($relays), function ($relay) {
return array_values(array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
}));
}
/**
* @return list<string>
*/
public function getNpubRelays($npub): array
{
$use = $this->getNpubRelayList10002Wire($npub);
if ($use === null) {
return [];
}
return self::relayWssListFromNip65Object($use);
}
/**

Loading…
Cancel
Save