6 changed files with 505 additions and 1 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue