From f969bc4a57a765016344f6a337763e6b3afc8157 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 24 Apr 2026 11:03:53 +0200 Subject: [PATCH] refactor caching/DB only replies in cache, everything else DB --- assets/controllers/progress_bar_controller.js | 6 +- config/packages/cache.yaml | 6 + config/services.yaml | 12 +- migrations/Version20260424130000.php | 31 ++ src/Command/PrewarmCommand.php | 4 +- src/Controller/ArticleController.php | 13 +- src/Entity/Event.php | 41 ++- src/Nostr/MagazineEventKeys.php | 62 ++++ src/Repository/EventRepository.php | 25 ++ src/Service/CacheService.php | 328 ++++++++++++------ src/Service/MagazineIndexStore.php | 126 +++---- src/Service/Nip09DeletionApplier.php | 191 ++++++---- src/Service/NostrClient.php | 145 +++++++- 13 files changed, 705 insertions(+), 285 deletions(-) create mode 100644 migrations/Version20260424130000.php create mode 100644 src/Nostr/MagazineEventKeys.php create mode 100644 src/Repository/EventRepository.php diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/progress_bar_controller.js index e53505d..c052a63 100644 --- a/assets/controllers/progress_bar_controller.js +++ b/assets/controllers/progress_bar_controller.js @@ -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); diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index b7e41f0..3a53955 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index ec077ca..e35d76a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: 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' diff --git a/migrations/Version20260424130000.php b/migrations/Version20260424130000.php new file mode 100644 index 0000000..4fcfdd8 --- /dev/null +++ b/migrations/Version20260424130000.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 0eb22bb..ac31e93 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -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).'…'); diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index d2bea45..f74e9c2 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -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 return $this->renderArticle( $article, $cacheService, - $articlesCache, $converter, $commentThreadLoader ); @@ -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 '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 Request $request, NostrClient $nostrClient, CacheService $cacheService, - CacheItemPoolInterface $articlesCache ): Response { $data = $request->getContent(); $descriptor = json_decode($data); diff --git a/src/Entity/Event.php b/src/Entity/Event.php index 196a701..a80f191 100644 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -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 #[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 $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 { diff --git a/src/Nostr/MagazineEventKeys.php b/src/Nostr/MagazineEventKeys.php new file mode 100644 index 0000000..ecd2c98 --- /dev/null +++ b/src/Nostr/MagazineEventKeys.php @@ -0,0 +1,62 @@ +convertToHex($npub); + } catch (\Throwable) { + $h = ''; + } + + return (\is_string($h) && 64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : ''; + } +} diff --git a/src/Repository/EventRepository.php b/src/Repository/EventRepository.php new file mode 100644 index 0000000..d15f9c5 --- /dev/null +++ b/src/Repository/EventRepository.php @@ -0,0 +1,25 @@ + + */ +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]); + } +} diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index 4b1d1e0..c2b7635 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -1,98 +1,117 @@ 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>} */ 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 $authorPubkeyHex + * @param array $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 return $out; } - /** - * @param list $authorPubkeyHex - * @param array $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>|array $tags + * @return list + */ + 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'); + }); } } diff --git a/src/Service/MagazineIndexStore.php b/src/Service/MagazineIndexStore.php index 341bf47..9c548cb 100644 --- a/src/Service/MagazineIndexStore.php +++ b/src/Service/MagazineIndexStore.php @@ -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 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(); } } diff --git a/src/Service/Nip09DeletionApplier.php b/src/Service/Nip09DeletionApplier.php index ba1385c..1802f3c 100644 --- a/src/Service/Nip09DeletionApplier.php +++ b/src/Service/Nip09DeletionApplier.php @@ -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; /** * 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 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 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 ++$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 } } - 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 ]; } - /** 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 return strtolower($a) === strtolower($b); } - /** - * @return list - */ - 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 $seenArticleIds */ @@ -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() ?? ''); diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index c084c53..fadc57f 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -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 $authorPubkeyHex + * @return array 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 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 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 } /** - * @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 + */ + 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 + */ + public function getNpubRelays($npub): array + { + $use = $this->getNpubRelayList10002Wire($npub); + if ($use === null) { + return []; + } + + return self::relayWssListFromNip65Object($use); } /**