Browse Source

auto-remove deletion events

imwald
Silberengel 1 week ago
parent
commit
2e6b7e178a
  1. 59
      src/Command/PrewarmCommand.php
  2. 1
      src/Enum/KindsEnum.php
  3. 5
      src/Repository/ArticleRepository.php
  4. 23
      src/Service/MagazineIndexStore.php
  5. 359
      src/Service/Nip09DeletionApplier.php
  6. 59
      src/Service/NostrClient.php

59
src/Command/PrewarmCommand.php

@ -9,6 +9,7 @@ use App\Repository\ArticleRepository;
use App\Service\ArticleCommentThreadLoader; use App\Service\ArticleCommentThreadLoader;
use App\Service\CacheService; use App\Service\CacheService;
use App\Service\MagazineRefresher; use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient; use App\Service\NostrClient;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
@ -26,12 +27,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
*/ */
#[AsCommand( #[AsCommand(
name: 'app:prewarm', name: 'app:prewarm',
description: 'Refresh magazine indices, profile metadata cache, and comment thread caches (use --no-comments to skip comments)', description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, and comment caches',
)] )]
final class PrewarmCommand extends Command final class PrewarmCommand extends Command
{ {
public function __construct( public function __construct(
private readonly MagazineRefresher $magazineRefresher, private readonly MagazineRefresher $magazineRefresher,
private readonly Nip09DeletionApplier $nip09DeletionApplier,
private readonly CacheService $cacheService, private readonly CacheService $cacheService,
private readonly NostrClient $nostrClient, private readonly NostrClient $nostrClient,
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
@ -46,6 +48,8 @@ final class PrewarmCommand extends Command
{ {
$this $this
->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch') ->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (30023/30024 DB + 30040 magazine cache)')
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache') ->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache') ->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh', '30') ->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh', '30')
@ -79,6 +83,59 @@ final class PrewarmCommand extends Command
// MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata. // MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata.
$this->disableCliExecutionTimeLimit(); $this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) {
$io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040)');
$sinceStr = (string) $input->getOption('deletion-since');
$since = strtotime($sinceStr);
if ($since === false) {
$since = strtotime('-2 month');
}
$until = time();
$deletionPubkeys = [];
foreach ($this->articleRepository->findDistinctAuthorPubkeys() as $pk) {
if (\is_string($pk) && 64 === \strlen($pk)) {
$deletionPubkeys[] = $pk;
}
}
$npubParam = (string) $this->params->get('npub');
if (str_starts_with($npubParam, 'npub')) {
try {
$sitePk = $keys->convertToHex($npubParam);
if ($sitePk !== '' && 64 === \strlen($sitePk) && !\in_array($sitePk, $deletionPubkeys, true)) {
$deletionPubkeys[] = $sitePk;
}
} catch (\Throwable) {
}
}
if ($deletionPubkeys === []) {
$io->note('No author pubkeys; skipping kind 5 deletion fetch.');
} else {
try {
$kind5 = $this->nostrClient->fetchKind5DeletionEventsForAuthors(
$deletionPubkeys,
$since,
$until,
40
);
$st = $this->nip09DeletionApplier->apply($kind5);
$io->writeln(sprintf(
'Kind 5 events: <info>%d</info> (deduped). Articles removed: <info>%d</info>; magazine root/category cache entries removed: <info>%d</info> / <info>%d</info>.',
\count($kind5),
$st['articles_removed'],
$st['magazine_roots'],
$st['magazine_categories']
));
} catch (\Throwable $e) {
$this->logger->error('app:prewarm NIP-09 failed', ['exception' => $e]);
$io->warning('NIP-09 step failed: '.$e->getMessage());
}
}
} else {
$io->note('Skipping NIP-09 deletions (--no-deletions).');
}
$this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-metadata')) { if (!$input->getOption('no-metadata')) {
$io->section('Author metadata (cache)'); $io->section('Author metadata (cache)');
$pubkeys = $this->articleRepository->findDistinctAuthorPubkeys(); $pubkeys = $this->articleRepository->findDistinctAuthorPubkeys();

1
src/Enum/KindsEnum.php

@ -5,6 +5,7 @@ namespace App\Enum;
enum KindsEnum: int enum KindsEnum: int
{ {
case METADATA = 0; // metadata, NIP-01 case METADATA = 0; // metadata, NIP-01
case DELETION_REQUEST = 5; // NIP-09
case TEXT_NOTE = 1; // text note, NIP-01, will not implement case TEXT_NOTE = 1; // text note, NIP-01, will not implement
case FOLLOWS = 3; case FOLLOWS = 3;
case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement

5
src/Repository/ArticleRepository.php

@ -125,6 +125,11 @@ class ArticleRepository extends ServiceEntityRepository
->getSingleColumnResult(); ->getSingleColumnResult();
} }
public function findOneByEventId(string $eventId): ?Article
{
return $this->findOneBy(['eventId' => $eventId]);
}
/** /**
* Find articles by author's public key * Find articles by author's public key
*/ */

23
src/Service/MagazineIndexStore.php

@ -73,6 +73,29 @@ final class MagazineIndexStore
$this->pool->save($item); $this->pool->save($item);
} }
/**
* Remove a cached category index (NIP-09 / local invalidation).
*
* @throws InvalidArgumentException
*/
public function deleteCategory(string $slug): void
{
if ($slug === '') {
return;
}
$this->pool->deleteItem(self::CAT_PREFIX.$slug);
}
/**
* 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));
}
private function rootKey(string $npub, string $dTag): string private function rootKey(string $npub, string $dTag): string
{ {
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag); return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);

359
src/Service/Nip09DeletionApplier.php

@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event as MagazineNostrEvent;
use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Applies NIP-09 (kind 5) deletion requests to local MySQL articles and magazine 30040 cache.
*
* 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.
*/
final class Nip09DeletionApplier
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ArticleRepository $articleRepository,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
/**
* @param list<object> $deletionEvents Kind-5 events from relays (e.g. {@see NostrClient::fetchKind5DeletionEventsForAuthors})
*
* @return array{articles_removed: int, magazine_roots: int, magazine_categories: int}
*/
public function apply(array $deletionEvents): array
{
$articlesRemoved = 0;
$articlesPendingFlush = 0;
$roots = 0;
$cats = 0;
$seenArticleIds = [];
foreach ($deletionEvents as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
$deletionPubkey = (string) ($ev->pubkey ?? '');
if (64 !== \strlen($deletionPubkey)) {
continue;
}
[$eIds, $eKinds] = $this->parseETags($ev);
$aAddrs = $this->parseATags($ev);
foreach ($eIds as $i => $eId) {
if (64 !== \strlen($eId)) {
continue;
}
$declared = $eKinds[$i] ?? null;
if ($declared !== null
&& !\in_array($declared, [30023, 30024, 30040, 1], true)) {
// Other kinds: we do not mirror in this app; skip.
continue;
}
if ($declared === 1) {
continue;
}
if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) {
++$articlesRemoved;
++$articlesPendingFlush;
continue;
}
// No article row: 30040 index (or mis-tagged kind); only skip unrelated kinds.
if ($declared === null || \in_array($declared, [30023, 30024, 30040], true)) {
$mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey);
if ($mag === 1) {
++$roots;
} elseif ($mag === 2) {
++$cats;
}
}
}
foreach ($aAddrs as $addr) {
$r = $this->removeByNip33Address($addr, $deletionPubkey, $seenArticleIds);
$articlesRemoved += $r['articles'];
$articlesPendingFlush += $r['articles'];
$roots += $r['roots'];
$cats += $r['cats'];
}
}
if ($articlesPendingFlush > 0) {
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
}
return [
'articles_removed' => $articlesRemoved,
'magazine_roots' => $roots,
'magazine_categories' => $cats,
];
}
/** 0 = none, 1 = root cache, 2 = category cache */
private function tryRemoveMagazine30040ByEventId(string $eventId, string $deletionPubkey): int
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($npub === '' || $dTag === '') {
return 0;
}
$root = $this->magazineIndexStore->getRoot($npub, $dTag);
if ($root === null) {
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,
]);
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;
}
}
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)) {
return $a === $b;
}
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
*/
private function removeArticleByEventIdIfValid(
string $eId,
string $deletionPubkey,
?int $declaredKind,
array &$seenArticleIds,
): bool {
if (isset($seenArticleIds[$eId])) {
return false;
}
$article = $this->articleRepository->findOneByEventId($eId);
if ($article === null) {
return false;
}
if (!$this->pubkeyEquals($article->getPubkey() ?? '', $deletionPubkey)) {
$this->logger->debug('NIP-09: ignore e tag (pubkey mismatch)', [
'event_id' => $eId,
]);
return false;
}
$k = $article->getKind()?->value;
if ($declaredKind !== null && $k !== null && $declaredKind !== $k) {
return false;
}
if ($k !== null && !\in_array($k, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
return false;
}
$this->entityManager->remove($article);
$seenArticleIds[$eId] = true;
$this->logger->notice('NIP-09: removed article from database', [
'event_id' => $eId,
'kind' => $k,
]);
return true;
}
/**
* NIP-33: `kind:pubkeyhex:d-identifier`
*
* @param array<string, true> $seenArticleIds
*
* @return array{articles: int, roots: int, cats: int}
*/
private function removeByNip33Address(string $addr, string $deletionPubkey, array &$seenArticleIds): array
{
$out = ['articles' => 0, 'roots' => 0, 'cats' => 0];
$parts = explode(':', $addr, 3);
if (\count($parts) < 3) {
return $out;
}
$kind = (int) $parts[0];
$pk = (string) $parts[1];
$d = trim((string) $parts[2]);
if ($d === '' || !$this->pubkeyEquals($pk, $deletionPubkey)) {
return $out;
}
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
$article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]);
if ($article !== null) {
$eid = (string) ($article->getEventId() ?? '');
$dedupeKey = $eid !== '' ? $eid : 'ps:'.$pk."\0".$d;
if (!isset($seenArticleIds[$dedupeKey])) {
$this->entityManager->remove($article);
$seenArticleIds[$dedupeKey] = true;
++$out['articles'];
$this->logger->notice('NIP-09: removed article (a tag)', [
'address' => $addr,
]);
}
}
return $out;
}
if ($kind === KindsEnum::PUBLICATION_INDEX->value) {
$npub = (string) $this->params->get('npub');
$siteD = (string) $this->params->get('d_tag');
$siteHex = '';
if (str_starts_with($npub, 'npub1')) {
try {
$h = (new Key())->convertToHex($npub);
if (64 === \strlen($h)) {
$siteHex = $h;
}
} catch (\Throwable) {
}
}
if ($npub !== '' && $siteD !== '' && $d === $siteD && $siteHex !== '' && $this->pubkeyEquals($pk, $siteHex)) {
$this->magazineIndexStore->deleteRoot($npub, $siteD);
++$out['roots'];
$this->logger->notice('NIP-09: removed magazine root (a tag)', ['address' => $addr]);
} else {
// Category cache is keyed by `d` only; the same d string can appear for different
// authors' 30040 events. Only remove if the cached event was authored by this deletion.
$cachedCat = $this->magazineIndexStore->getCategory($d);
if ($cachedCat === null) {
$this->logger->debug('NIP-09: skip category delete (nothing cached for d)', [
'address' => $addr,
'd' => $d,
]);
} elseif (!$this->pubkeyEquals($cachedCat->getPubkey(), $deletionPubkey)) {
$this->logger->debug('NIP-09: skip category delete (cached index author != deletion author)', [
'address' => $addr,
'd' => $d,
]);
} else {
$this->magazineIndexStore->deleteCategory($d);
++$out['cats'];
$this->logger->notice('NIP-09: removed magazine category (a tag)', [
'address' => $addr,
'd' => $d,
]);
}
}
}
return $out;
}
/**
* @return array{0: list<string>, 1: list<?int>} e-ids and parallel k kinds (NIP-09 example order)
*/
private function parseETags(object $ev): array
{
$eIds = [];
$kinds = [];
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) || !isset($tag[0], $tag[1])) {
continue;
}
if ($tag[0] === 'e') {
$eIds[] = (string) $tag[1];
}
if ($tag[0] === 'k') {
$kinds[] = (int) $tag[1];
}
}
$pairs = [];
for ($i = 0; $i < \count($eIds); ++$i) {
$pairs[] = $kinds[$i] ?? null;
}
return [$eIds, $pairs];
}
/**
* @return list<string> NIP-33 addresses
*/
private function parseATags(object $ev): array
{
$a = [];
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$a[] = (string) $tag[1];
}
return $a;
}
}

59
src/Service/NostrClient.php

@ -316,6 +316,65 @@ class NostrClient
return $byPub; return $byPub;
} }
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
* @param list<string> $authorPubkeyHex
* @return list<stdClass> Deduplicated by event `id` (highest {@see created_at} kept)
*/
public function fetchKind5DeletionEventsForAuthors(
array $authorPubkeyHex,
int $since,
int $until,
int $authorsPerRequest = 40,
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === [] || $since >= $until) {
return [];
}
$authorsPerRequest = max(1, min(100, $authorsPerRequest));
$byId = [];
foreach (array_chunk($authorPubkeyHex, $authorsPerRequest) as $chunk) {
$request = $this->createNostrRequest(
kinds: [KindsEnum::DELETION_REQUEST],
filters: [
'authors' => $chunk,
'since' => $since,
'until' => $until,
],
);
$t0 = microtime(true);
$events = $this->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$this->logger->info('nostr.nip09.kind5_chunk', [
'authors' => \count($chunk),
'raw_events' => \count($events),
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
foreach ($events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
$id = (string) ($ev->id ?? '');
if (64 !== \strlen($id)) {
continue;
}
$t = (int) ($ev->created_at ?? 0);
if (isset($byId[$id]) && $t <= (int) ($byId[$id]->created_at ?? 0)) {
continue;
}
$byId[$id] = $ev;
}
}
return array_values($byId);
}
/** /**
* @throws \Exception * @throws \Exception
*/ */

Loading…
Cancel
Save