You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
263 lines
10 KiB
263 lines
10 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Service; |
|
|
|
use App\Entity\User; |
|
use App\Enum\KindsEnum; |
|
use App\Util\NostrEventTags; |
|
use Psr\Log\LoggerInterface; |
|
use swentel\nostr\Event\Event as NostrWireEvent; |
|
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
|
|
|
/** |
|
* Validates NIP-07–signed kind-30040 magazine batches from the site owner, publishes to article relays, |
|
* updates {@see MagazineIndexStore}, then ingests listed long-form coordinates into MySQL so the UI |
|
* can resolve new `a` tags without waiting for cron {@see NostrClient::ingestLongformForCategoryCoordinates}. |
|
*/ |
|
final class MagazineHierarchyPublishService |
|
{ |
|
private const STALE_EVENT_MAX_AGE_SEC = 600; |
|
|
|
public function __construct( |
|
private readonly NostrClient $nostrClient, |
|
private readonly MagazineIndexStore $store, |
|
private readonly NostrWireEventMerge $wireMerge, |
|
private readonly NostrKeyHelper $nostrKeyHelper, |
|
private readonly ParameterBagInterface $params, |
|
private readonly LoggerInterface $logger, |
|
) { |
|
} |
|
|
|
/** |
|
* @param array<int, mixed> $rawEvents Decoded JSON event objects |
|
* |
|
* @return array{ok: true, published: int, stored: int, longform_ingest_addresses: int}|array{ok: false, error: string, code: int} |
|
*/ |
|
public function publishOwnerMagazineBatch(User $user, array $rawEvents): array |
|
{ |
|
$npub = (string) $this->params->get('npub'); |
|
$rootD = (string) $this->params->get('d_tag'); |
|
$userNpub = $user->getNpub() ?? ''; |
|
if ($userNpub === '' || !hash_equals($npub, $userNpub)) { |
|
return ['ok' => false, 'error' => 'Only the configured magazine npub may publish this batch.', 'code' => 403]; |
|
} |
|
$ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); |
|
if ($ownerHex === '' || 64 !== \strlen($ownerHex) || !ctype_xdigit($ownerHex)) { |
|
return ['ok' => false, 'error' => 'Invalid owner pubkey configuration.', 'code' => 500]; |
|
} |
|
|
|
if ($rawEvents === []) { |
|
return ['ok' => false, 'error' => 'No events in batch.', 'code' => 400]; |
|
} |
|
if (\count($rawEvents) > 200) { |
|
return ['ok' => false, 'error' => 'Too many events in one batch.', 'code' => 400]; |
|
} |
|
|
|
/** @var array<string, NostrWireEvent> $byD */ |
|
$byD = []; |
|
foreach ($rawEvents as $i => $raw) { |
|
if (!\is_array($raw)) { |
|
return ['ok' => false, 'error' => 'Invalid event at index '.$i.'.', 'code' => 400]; |
|
} |
|
try { |
|
$json = json_encode($raw, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); |
|
} catch (\JsonException) { |
|
return ['ok' => false, 'error' => 'Invalid JSON for event at index '.$i.'.', 'code' => 400]; |
|
} |
|
$wire = NostrWireEvent::fromVerified($json); |
|
if ($wire === null) { |
|
return ['ok' => false, 'error' => 'Invalid or unverifiable event at index '.$i.'.', 'code' => 400]; |
|
} |
|
if ($wire->getKind() !== KindsEnum::PUBLICATION_INDEX->value) { |
|
return ['ok' => false, 'error' => 'Event at index '.$i.' must be kind 30040.', 'code' => 400]; |
|
} |
|
if (!hash_equals($ownerHex, strtolower($wire->getPublicKey()))) { |
|
return ['ok' => false, 'error' => 'Event pubkey does not match the magazine owner.', 'code' => 403]; |
|
} |
|
$now = time(); |
|
if ($now - $wire->getCreatedAt() > self::STALE_EVENT_MAX_AGE_SEC || $wire->getCreatedAt() > $now + 60) { |
|
return ['ok' => false, 'error' => 'Event created_at out of range at index '.$i.'.', 'code' => 400]; |
|
} |
|
$d = $this->extractSingleDTag($wire->getTags()); |
|
if ($d === '') { |
|
return ['ok' => false, 'error' => 'Missing or duplicate #d tag at index '.$i.'.', 'code' => 400]; |
|
} |
|
if (isset($byD[$d])) { |
|
return ['ok' => false, 'error' => 'Duplicate #d tag in batch: '.$d.'.', 'code' => 400]; |
|
} |
|
$err = $this->validateTagsForWire($wire->getTags(), $ownerHex); |
|
if ($err !== null) { |
|
return ['ok' => false, 'error' => $err.' (event #d '.$d.')', 'code' => 400]; |
|
} |
|
$byD[$d] = $wire; |
|
} |
|
|
|
$relays = $this->nostrClient->getArticleWriteRelayUrls(); |
|
if ($relays === []) { |
|
return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500]; |
|
} |
|
|
|
$published = 0; |
|
foreach ($byD as $wire) { |
|
$res = $this->nostrClient->publishEvent($wire, $relays); |
|
$okRelays = 0; |
|
foreach ($res as $relayRes) { |
|
if ($relayRes instanceof \Throwable) { |
|
continue; |
|
} |
|
++$okRelays; |
|
} |
|
if ($okRelays < 1) { |
|
$this->logger->warning('magazine_hierarchy.publish_failed_all_relays', [ |
|
'id' => $wire->getId(), |
|
]); |
|
|
|
return ['ok' => false, 'error' => 'Publish failed on all relays for event '.$wire->getId().'.', 'code' => 502]; |
|
} |
|
++$published; |
|
} |
|
|
|
$stored = 0; |
|
foreach ($byD as $d => $wire) { |
|
$entity = $this->wireMerge->magazineEventToPublicationEntity(json_decode($wire->toJson(), true)); |
|
if ($entity === null) { |
|
return ['ok' => false, 'error' => 'Could not map event to storage model (#d '.$d.').', 'code' => 500]; |
|
} |
|
if (hash_equals($rootD, $d)) { |
|
$this->store->putRoot($npub, $rootD, $entity); |
|
} else { |
|
$this->store->putCategory($d, $entity); |
|
} |
|
++$stored; |
|
} |
|
|
|
$longformAddresses = $this->collectLongformCoordinatesFromBatch($byD); |
|
if ($longformAddresses !== []) { |
|
try { |
|
$this->nostrClient->ingestLongformForCategoryCoordinates($longformAddresses); |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('magazine_hierarchy.longform_ingest_after_publish_failed', [ |
|
'message' => $e->getMessage(), |
|
]); |
|
} |
|
} |
|
|
|
$this->logger->info('magazine_hierarchy.published', [ |
|
'published' => $published, |
|
'stored' => $stored, |
|
'longform_ingest_addresses' => \count($longformAddresses), |
|
]); |
|
|
|
return [ |
|
'ok' => true, |
|
'published' => $published, |
|
'stored' => $stored, |
|
'longform_ingest_addresses' => \count($longformAddresses), |
|
]; |
|
} |
|
|
|
/** |
|
* @param array<int, mixed> $tags |
|
*/ |
|
private function validateTagsForWire(array $tags, string $ownerHex): ?string |
|
{ |
|
foreach ($tags as $row) { |
|
if (!NostrEventTags::tagNameMatches($row, 'a')) { |
|
continue; |
|
} |
|
$seq = NostrEventTags::rowToStringList($row); |
|
if ($seq === null || !isset($seq[1])) { |
|
return 'Malformed `a` tag'; |
|
} |
|
$coord = trim((string) $seq[1]); |
|
$parts = explode(':', $coord, 3); |
|
if (\count($parts) < 3) { |
|
return 'Invalid `a` coordinate'; |
|
} |
|
$kind = (int) $parts[0]; |
|
$pk = strtolower(trim((string) $parts[1])); |
|
$id = trim((string) $parts[2]); |
|
if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
|
return 'Invalid `a` coordinate pubkey or identifier'; |
|
} |
|
if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) { |
|
return 'Nested 30040 `a` tags must use the magazine owner pubkey'; |
|
} |
|
$allowedKinds = array_merge([KindsEnum::PUBLICATION_INDEX->value], KindsEnum::longformKindValues()); |
|
if (!\in_array($kind, $allowedKinds, true)) { |
|
return 'Unsupported kind in `a` tag (only 30040, 30023, 30024, 30817)'; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Long-form `a` coordinates from this publish batch (30023 / 30024 / 30817) for immediate DB sync. |
|
* |
|
* @param array<string, NostrWireEvent> $byD |
|
* |
|
* @return list<string> |
|
*/ |
|
private function collectLongformCoordinatesFromBatch(array $byD): array |
|
{ |
|
$out = []; |
|
foreach ($byD as $wire) { |
|
foreach ($wire->getTags() as $row) { |
|
if (!NostrEventTags::tagNameMatches($row, 'a')) { |
|
continue; |
|
} |
|
$seq = NostrEventTags::rowToStringList($row); |
|
if ($seq === null || !isset($seq[1])) { |
|
continue; |
|
} |
|
$coord = trim((string) $seq[1]); |
|
$parts = explode(':', $coord, 3); |
|
if (\count($parts) < 3) { |
|
continue; |
|
} |
|
$kind = (int) $parts[0]; |
|
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { |
|
continue; |
|
} |
|
$pk = strtolower(trim((string) $parts[1])); |
|
$id = trim((string) $parts[2]); |
|
if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
|
continue; |
|
} |
|
$out[] = $kind.':'.$pk.':'.$id; |
|
} |
|
} |
|
|
|
return array_values(array_unique($out)); |
|
} |
|
|
|
/** |
|
* @param array<int, mixed> $tags |
|
*/ |
|
private function extractSingleDTag(array $tags): string |
|
{ |
|
$found = ''; |
|
foreach ($tags as $row) { |
|
if (!NostrEventTags::tagNameMatches($row, 'd')) { |
|
continue; |
|
} |
|
$seq = NostrEventTags::rowToStringList($row); |
|
if ($seq === null || !isset($seq[1])) { |
|
continue; |
|
} |
|
$v = trim((string) $seq[1]); |
|
if ($v === '') { |
|
continue; |
|
} |
|
if ($found !== '') { |
|
return ''; |
|
} |
|
$found = $v; |
|
} |
|
|
|
return $found; |
|
} |
|
}
|
|
|