diff --git a/config/services.yaml b/config/services.yaml index 5d6beda..90579d6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -77,6 +77,9 @@ services: App\Service\Nip05VerificationService: arguments: $appCache: '@cache.app' + App\Service\CacheService: + tags: + - { name: kernel.reset, method: reset } when@test: services: diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 76dd435..0dbbd97 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -379,6 +379,17 @@ final class PrewarmCommand extends Command $this->waitForSiteWellKnownBeforeVerification($io, $domain); } $io->writeln('Verifying NIP-05 (HTTPS /.well-known/nostr.json, per identifier)…'); + $npubsForVerify = []; + foreach ($toWarm as $hex) { + if (64 !== \strlen($hex) || !ctype_xdigit($hex)) { + continue; + } + try { + $npubsForVerify[] = $this->nostrKeyHelper->convertPublicKeyToBech32(strtolower($hex)); + } catch (\Throwable) { + } + } + $this->cacheService->prefetchMetadataForNpubs($npubsForVerify); $nt = 0; $nv = 0; foreach ($toWarm as $hex) { diff --git a/src/Repository/EventRepository.php b/src/Repository/EventRepository.php index d15f9c5..94d8386 100644 --- a/src/Repository/EventRepository.php +++ b/src/Repository/EventRepository.php @@ -22,4 +22,35 @@ class EventRepository extends ServiceEntityRepository { return $this->findOneBy(['coreRowKey' => $key]); } + + /** + * @param list $keys + * + * @return array keyed by coreRowKey + */ + public function findByCoreRowKeys(array $keys): array + { + $keys = array_values(array_unique(array_filter( + $keys, + static fn (mixed $k): bool => \is_string($k) && $k !== '', + ))); + if ($keys === []) { + return []; + } + /** @var list $rows */ + $rows = $this->createQueryBuilder('e') + ->andWhere('e.coreRowKey IN (:keys)') + ->setParameter('keys', $keys) + ->getQuery() + ->getResult(); + $out = []; + foreach ($rows as $row) { + $k = $row->getCoreRowKey(); + if ($k !== null && $k !== '') { + $out[$k] = $row; + } + } + + return $out; + } } diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index 290fd83..5c6ff40 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -338,6 +338,19 @@ final class ArticleBodyHighlightInjector */ private function buildHighlightAuthorsJson(array $group): string { + $npubsForPrefetch = []; + foreach ($group as $h) { + $pk = $h->getAuthorPubkey(); + if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { + continue; + } + try { + $npubsForPrefetch[] = $this->nostrKeyHelper->convertPublicKeyToBech32($pk); + } catch (\Throwable) { + } + } + $this->highlightAuthorMetadata->prefetchMetadataForNpubs($npubsForPrefetch); + $byNpub = []; foreach ($group as $h) { $eidH = $h->getEventId(); diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index 8a9dd4e..6a80129 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -9,9 +9,20 @@ use App\Nostr\MagazineEventKeys; use App\Repository\EventRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Contracts\Service\ResetInterface; -readonly class CacheService implements HighlightAuthorMetadataProvider +final class CacheService implements HighlightAuthorMetadataProvider, ResetInterface { + /** + * @var array>, nip30_custom_emojis: list}> + */ + private array $requestBundlesByHex = []; + + /** @var array lowercase hex pubkey => npub */ + private array $pendingHexToNpub = []; + + private int $metadataBatchDepth = 0; + public function __construct( private NostrClient $nostrClient, private EntityManagerInterface $entityManager, @@ -23,6 +34,13 @@ readonly class CacheService implements HighlightAuthorMetadataProvider ) { } + public function reset(): void + { + $this->requestBundlesByHex = []; + $this->pendingHexToNpub = []; + $this->metadataBatchDepth = 0; + } + public function getMetadata(string $npub): \stdClass { return $this->getMetadataBundle($npub)['content']; @@ -37,41 +55,129 @@ readonly class CacheService implements HighlightAuthorMetadataProvider if ($authorHex === null) { return $this->placeholderMetadataBundle($npub); } + if (isset($this->requestBundlesByHex[$authorHex])) { + return $this->requestBundlesByHex[$authorHex]; + } $row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex)); if ($row !== null) { - return $this->bundleFromKind0EventRow($row, $npub); + $bundle = $this->bundleFromKind0EventRow($row, $npub); + $this->requestBundlesByHex[$authorHex] = $bundle; + + return $bundle; } - try { - $ev = $this->nostrClient->getNpubMetadata($npub); - if (!\is_object($ev)) { - return $this->placeholderMetadataBundle($npub); + $this->pendingHexToNpub[$authorHex] = $npub; + $this->runPendingMetadataBatch(); + + return $this->requestBundlesByHex[$authorHex] ?? $this->placeholderMetadataBundle($npub); + } + + /** + * @param list $npubs + */ + public function prefetchMetadataForNpubs(array $npubs): void + { + foreach ($npubs as $npub) { + if (!\is_string($npub) || $npub === '') { + continue; } - $nip30 = $this->nip30EmojiCatalogBuilder->buildMergedCatalog($ev, null, []); - $this->replaceByCoreKey( - MagazineEventKeys::profileKind0($authorHex), - Event::STORAGE_PROFILE_KIND0, - $ev, - $nip30, - ); - $tags = self::normalizeEventTagsList($ev->tags ?? null); - $content = $this->decodeKind0ContentObject($ev); - if ($this->isPlaceholderContent($content, $npub)) { - $content = $this->namePlaceholderNpubObject($npub); + $authorHex = $this->npubToAuthorHex64($npub); + if ($authorHex === null || isset($this->requestBundlesByHex[$authorHex])) { + continue; + } + $this->pendingHexToNpub[$authorHex] = $npub; + } + $this->runPendingMetadataBatch(); + } + + /** + * @param list $pubkeyHex 64-char hex pubkeys (any case) + */ + public function prefetchMetadataForPubkeyHexes(array $pubkeyHex): void + { + $npubs = []; + foreach ($pubkeyHex as $hex) { + if (!\is_string($hex) || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { + continue; } + try { + $npubs[] = $this->nostrKeyHelper->convertPublicKeyToBech32(strtolower($hex)); + } catch (\Throwable) { + } + } + $this->prefetchMetadataForNpubs($npubs); + } + + private function runPendingMetadataBatch(): void + { + if ($this->pendingHexToNpub === []) { + return; + } + ++$this->metadataBatchDepth; + try { + do { + if ($this->pendingHexToNpub === []) { + break; + } + $this->flushPendingMetadataFetches(); + } while ($this->pendingHexToNpub !== []); + } finally { + --$this->metadataBatchDepth; + } + } + + private function flushPendingMetadataFetches(): void + { + if ($this->pendingHexToNpub === []) { + return; + } + $pending = $this->pendingHexToNpub; + $this->pendingHexToNpub = []; - return [ - 'content' => $content, - 'kind0_tags' => $tags, - 'nip30_custom_emojis' => $nip30, - ]; - } catch (\Exception $e) { + $keys = []; + foreach (array_keys($pending) as $hex) { + $keys[] = MagazineEventKeys::profileKind0($hex); + } + $rowsByKey = $this->eventRepository->findByCoreRowKeys($keys); + + $relayHex = []; + foreach ($pending as $hex => $npub) { + $key = MagazineEventKeys::profileKind0($hex); + if (isset($rowsByKey[$key])) { + $this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsByKey[$key], $npub); + continue; + } + $relayHex[] = $hex; + } + if ($relayHex === []) { + return; + } + + try { + $fetched = $this->nostrClient->fetchProfilePrewarmWireBundlesForAuthors($relayHex); + $this->putPrewarmMetadataBatch($relayHex, $fetched); + } catch (\Throwable $e) { + $this->logger->warning('Profile metadata batch fetch failed.', [ + 'authors' => \count($relayHex), + 'exception' => $e, + ]); + } + + $rowsAfterRelay = $this->eventRepository->findByCoreRowKeys(array_map( + static fn (string $hex): string => MagazineEventKeys::profileKind0($hex), + $relayHex, + )); + foreach ($relayHex as $hex) { + $npub = $pending[$hex]; + $key = MagazineEventKeys::profileKind0($hex); + if (isset($rowsAfterRelay[$key])) { + $this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsAfterRelay[$key], $npub); + continue; + } $this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [ 'npub' => $npub, - 'exception' => $e->getPrevious() ?? $e, ]); + $this->requestBundlesByHex[$hex] = $this->placeholderMetadataBundle($npub); } - - return $this->placeholderMetadataBundle($npub); } /** diff --git a/src/Service/FeaturedAuthorListedRows.php b/src/Service/FeaturedAuthorListedRows.php index d612858..447fbef 100644 --- a/src/Service/FeaturedAuthorListedRows.php +++ b/src/Service/FeaturedAuthorListedRows.php @@ -33,9 +33,11 @@ final class FeaturedAuthorListedRows return $fromDb; } + $hexes = \array_slice($this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(), 0, $limit); + $this->cacheService->prefetchMetadataForPubkeyHexes($hexes); + $authors = []; - $hexes = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(); - foreach (\array_slice($hexes, 0, $limit) as $hex) { + foreach ($hexes as $hex) { try { $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex); } catch (\Throwable) { @@ -52,8 +54,14 @@ final class FeaturedAuthorListedRows */ public function buildListedByLocalPartPage(int $limit, int $offset = 0): array { + $listed = $this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset); + $this->cacheService->prefetchMetadataForPubkeyHexes(array_map( + static fn ($fa) => $fa->getPubkeyHex(), + $listed, + )); + $authors = []; - foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) { + foreach ($listed as $fa) { try { $npub = $this->nostrKeyHelper->convertPublicKeyToBech32($fa->getPubkeyHex()); } catch (\Throwable) { diff --git a/src/Service/HighlightAuthorMetadataProvider.php b/src/Service/HighlightAuthorMetadataProvider.php index c8e0eed..1eb74d7 100644 --- a/src/Service/HighlightAuthorMetadataProvider.php +++ b/src/Service/HighlightAuthorMetadataProvider.php @@ -10,4 +10,9 @@ namespace App\Service; interface HighlightAuthorMetadataProvider { public function getMetadata(string $npub): \stdClass; + + /** + * @param list $npubs + */ + public function prefetchMetadataForNpubs(array $npubs): void; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 4574a85..510305e 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -386,6 +386,10 @@ class NostrClient */ public function getNpubMetadata($npub): \stdClass { + $authorHex = $this->wireMerge->authorIdentToHexLower($npub); + if ($authorHex === null) { + throw new \Exception('Invalid npub for metadata: '.$npub); + } $relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList()); $relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); $relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried); @@ -393,7 +397,7 @@ class NostrClient $request = $this->nostrRelayQuery->createNostrRequest( defaultRelaySet: $this->defaultRelaySet, kinds: [KindsEnum::METADATA], - filters: ['authors' => [$npub]], + filters: ['authors' => [$authorHex]], relaySet: $relaySet ); @@ -410,10 +414,6 @@ class NostrClient throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')'); } $byAddr = $this->wireMerge->mergeKind0EventsByReplaceableAddress($events); - $authorHex = $this->wireMerge->npubToHexPubkey($npub); - if ($authorHex === null) { - throw new \Exception('Invalid npub for metadata: '.$npub); - } $key = '0:'.$authorHex; if (!isset($byAddr[$key])) { throw new \Exception('No kind-0 metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')'); @@ -431,6 +431,10 @@ class NostrClient */ public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array { + $authorHex = $this->wireMerge->authorIdentToHexLower($npub); + if ($authorHex === null) { + return []; + } $relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList()); $relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried)); $relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried); @@ -438,7 +442,7 @@ class NostrClient $request = $this->nostrRelayQuery->createNostrRequest( defaultRelaySet: $this->defaultRelaySet, kinds: [KindsEnum::PAYMENT_TARGETS], - filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))], + filters: ['authors' => [$authorHex], 'limit' => max(1, min(50, $limit))], relaySet: $relaySet ); $events = $this->nostrRelayQuery->processResponse( @@ -463,11 +467,17 @@ class NostrClient public function getNpubLongForm($npub): void { + $authorHex = $this->wireMerge->authorIdentToHexLower($npub); + if ($authorHex === null) { + $this->logger->warning('nostr.longform_by_author.invalid_npub', ['npub' => $npub]); + + return; + } $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $filter = new Filter(); $filter->setKinds([KindsEnum::LONGFORM]); - $filter->setAuthors([$npub]); + $filter->setAuthors([$authorHex]); $filter->setSince(strtotime('-6 months')); // too much? $requestMessage = new RequestMessage($subscriptionId, [$filter]); @@ -843,10 +853,14 @@ class NostrClient */ public function getNpubRelayList10002Wire($npub): ?object { + $authorHex = $this->wireMerge->authorIdentToHexLower($npub); + if ($authorHex === null) { + return null; + } $request = $this->nostrRelayQuery->createNostrRequest( defaultRelaySet: $this->defaultRelaySet, kinds: [KindsEnum::RELAY_LIST], - filters: ['authors' => [$npub]], + filters: ['authors' => [$authorHex]], relaySet: $this->defaultRelaySet ); $response = $this->nostrRelayQuery->processResponse($request->send(), function ($received) { @@ -1713,7 +1727,7 @@ class NostrClient defaultRelaySet: $this->defaultRelaySet, relaySet: $relaySet, kinds: [KindsEnum::PUBLICATION_INDEX], - filters: ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]], + filters: ['authors' => [$authorHex], 'tag' => ['#d', [(string) $dTag]]], ); $this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [ 'npub' => $npub, diff --git a/src/Service/NostrWireEventMerge.php b/src/Service/NostrWireEventMerge.php index 497d65c..558387e 100644 --- a/src/Service/NostrWireEventMerge.php +++ b/src/Service/NostrWireEventMerge.php @@ -375,16 +375,18 @@ final readonly class NostrWireEventMerge if ($raw instanceof PublicationEventEntity) { return $raw; } - if (!\is_object($raw)) { - return null; - } - - try { - $data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return null; - } - if (!\is_array($data)) { + if (\is_array($raw)) { + $data = $raw; + } elseif (\is_object($raw)) { + try { + $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; + } + } else { return null; } $entity = new PublicationEventEntity(); diff --git a/src/Twig/ArticleCardCoverExtension.php b/src/Twig/ArticleCardCoverExtension.php index 16b0545..3bafab2 100644 --- a/src/Twig/ArticleCardCoverExtension.php +++ b/src/Twig/ArticleCardCoverExtension.php @@ -42,11 +42,34 @@ final class ArticleCardCoverExtension extends AbstractExtension { return [ new TwigFunction('article_card_cover', $this->articleCardCover(...)), + new TwigFunction('prefetch_article_card_covers', $this->prefetchArticleCardCovers(...)), new TwigFunction('article_og_image', $this->articleOgImage(...)), new TwigFunction('site_og_image', $this->siteOgImage(...)), ]; } + /** + * Batch kind-0 profile lookups before a list of cards (one relay REQ per chunk, not per tile). + * + * @param iterable $items Rows with optional `pubkey` (64-char hex) + */ + public function prefetchArticleCardCovers(iterable $items): void + { + $hexes = []; + foreach ($items as $item) { + if (\is_object($item) && isset($item->article)) { + $item = $item->article; + } elseif (\is_array($item) && isset($item['article'])) { + $item = $item['article']; + } + $hex = $this->pubkeyHexFromItem($item); + if ($hex !== null) { + $hexes[] = $hex; + } + } + $this->cacheService->prefetchMetadataForPubkeyHexes($hexes); + } + /** * Branded site Open Graph image (home, category lists, base layout default): not tied to any article or author. * @@ -167,4 +190,23 @@ final class ArticleCardCoverExtension extends AbstractExtension { return $this->packages->getUrl(self::DEFAULT_PACKAGE_IMAGE); } + + private function pubkeyHexFromItem(mixed $item): ?string + { + $raw = null; + if (\is_object($item) && isset($item->pubkey)) { + $raw = $item->pubkey; + } elseif (\is_array($item) && isset($item['pubkey'])) { + $raw = $item['pubkey']; + } + if (!\is_string($raw)) { + return null; + } + $hex = strtolower(trim($raw)); + if (64 !== \strlen($hex) || !ctype_xdigit($hex)) { + return null; + } + + return $hex; + } } diff --git a/templates/components/Organisms/FeaturedList.html.twig b/templates/components/Organisms/FeaturedList.html.twig index 6e3c851..ba154b9 100644 --- a/templates/components/Organisms/FeaturedList.html.twig +++ b/templates/components/Organisms/FeaturedList.html.twig @@ -6,6 +6,7 @@ role="region" aria-label="{{ title|e('html_attr') }}" > + {% do prefetch_article_card_covers(list) %} {% for item in list %}