Compare commits

..

3 Commits

  1. 103
      assets/styles/app.css
  2. 4
      config/unfold.yaml
  3. 163
      src/Command/PrewarmCommand.php
  4. 34
      src/Controller/AuthorController.php
  5. 9
      src/Controller/DefaultController.php
  6. 354
      src/Controller/SeoController.php
  7. 1
      src/Enum/KindsEnum.php
  8. 21
      src/Repository/ArticleRepository.php
  9. 82
      src/Service/CacheService.php
  10. 48
      src/Service/MagazineContentService.php
  11. 14
      src/Service/MagazineIndexStore.php
  12. 21
      src/Service/MagazineRefresher.php
  13. 46
      src/Service/NostrClient.php
  14. 201
      src/Service/ProfileIdentityLinksBuilder.php
  15. 419
      src/Service/ProfilePaymentLinksBuilder.php
  16. 20
      templates/home.html.twig
  17. 40
      templates/pages/author.html.twig

103
assets/styles/app.css

@ -534,8 +534,86 @@ footer a { @@ -534,8 +534,86 @@ footer a {
margin-top: 0.25em;
}
.author-profile__header-meta {
margin-top: 0.5rem;
max-width: 28rem;
margin-left: auto;
margin-right: auto;
text-align: left;
}
.author-profile__identity {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
}
.author-profile__identity-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
margin: 0.35rem 0;
font-size: 0.9rem;
line-height: 1.35;
}
.author-profile__identity-type {
flex: 0 0 7.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
}
.author-profile__identity-link {
word-break: break-all;
min-width: 0;
}
.author-profile__payments {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
max-width: 100%;
text-align: left;
}
.author-profile__payment {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
margin: 0.35rem 0;
font-size: 0.9rem;
line-height: 1.35;
}
.author-profile__payment-type {
flex: 0 0 7.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
}
.author-profile__payment-link {
word-break: break-all;
min-width: 0;
}
.author-profile__jumble {
margin: 1rem 0 0;
text-align: center;
}
.author-profile__about {
text-align: left;
margin-top: 1rem;
}
.author-profile__divider {
@ -770,6 +848,31 @@ a:focus-visible { @@ -770,6 +848,31 @@ a:focus-visible {
outline-offset: 2px;
}
.home-subscribe {
margin-bottom: 1.75rem;
padding: 1rem 0 0;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
}
.home-subscribe__title {
font-size: 1.15rem;
margin: 0 0 0.35rem;
}
.home-subscribe__hint {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--color-text);
opacity: 0.85;
}
.home-subscribe__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.6rem;
margin-bottom: 1.25rem;
}
@media (max-width: 600px) {
.header__logo .brand {
font-size: clamp(0.95rem, 4.8vw, 1.25rem);

4
config/unfold.yaml

@ -17,6 +17,8 @@ parameters: @@ -17,6 +17,8 @@ parameters:
- 'wss://relay.damus.io'
- 'wss://nos.lol'
- 'wss://profiles.nostr1.com'
- 'wss://thecitadel.nostr1.com'
- 'wss://nostr.wine'
# Example:
# article_relays:
# - 'wss://nos.lol'
@ -29,6 +31,8 @@ parameters: @@ -29,6 +31,8 @@ parameters:
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
d_tag: 'newsroom-magazine-on-imwald-by-laeserin'
community_articles: true
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users'
external_links:
- title: "Unfold"
url: "https://github.com/decent-newsroom/unfold"

163
src/Command/PrewarmCommand.php

@ -15,6 +15,7 @@ use Psr\Log\LoggerInterface; @@ -15,6 +15,7 @@ use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@ -70,11 +71,45 @@ final class PrewarmCommand extends Command @@ -70,11 +71,45 @@ final class PrewarmCommand extends Command
$budget = max(1, (int) $input->getOption('magazine-budget'));
$io->section('Magazine index (kinds 30040)');
try {
$this->magazineRefresher->refreshFromRelays($budget, []);
$hb = (object) ['silent' => false];
$bar = null;
$this->runWithPcntlHeartbeat($io, 5, function () use ($io, $budget, &$bar, $hb): void {
$this->magazineRefresher->refreshFromRelays($budget, [], function (string $phase, array $p) use ($io, &$bar, $hb): void {
if ($phase === 'before_root') {
$io->writeln(' <comment>Fetching magazine root index (30040) from relays…</comment>');
} elseif ($phase === 'aborted') {
$hb->silent = true;
$this->cancelPcntlAlarm();
} elseif ($phase === 'after_root') {
$hb->silent = true;
$this->cancelPcntlAlarm();
$bar = $this->createPrewarmProgressBar(
$io,
max(1, (int) ($p['total_steps'] ?? 1)),
'Magazine index',
);
$bar->start();
$bar->setProgress(1);
} elseif ($phase === 'category_fetched' && $bar !== null) {
$bar->advance(1);
$slug = (string) ($p['slug'] ?? '');
if (strlen($slug) > 70) {
$slug = substr($slug, 0, 67).'…';
}
$bar->setMessage($slug !== '' ? 'Category: '.$slug : 'Category');
}
});
}, $hb);
if ($bar !== null) {
$bar->finish();
$io->newLine(2);
}
$io->success('Magazine indices refreshed (within budget).');
} catch (\Throwable $e) {
$this->logger->error('app:prewarm magazine failed', ['e' => $e]);
$io->warning('Magazine refresh failed: '.$e->getMessage());
} finally {
$this->cancelPcntlAlarm();
}
} else {
$io->note('Skipping magazine (--no-magazine).');
@ -110,13 +145,26 @@ final class PrewarmCommand extends Command @@ -110,13 +145,26 @@ final class PrewarmCommand extends Command
if ($deletionPubkeys === []) {
$io->note('No author pubkeys; skipping kind 5 deletion fetch.');
} else {
$chunks = array_chunk($deletionPubkeys, 40);
$nChunks = \count($chunks);
$nipBar = $this->createPrewarmProgressBar($io, max(1, $nChunks), 'NIP-09 kind 5 (by author batch)');
$nipBar->start();
try {
$kind5 = $this->nostrClient->fetchKind5DeletionEventsForAuthors(
$deletionPubkeys,
$since,
$until,
40
40,
function (int $index, int $totalChunks, int $pubkeyCount) use ($nipBar): void {
$nipBar->advance(1);
$nipBar->setMessage(sprintf('Batch %d/%d · %d pubkeys', $index, $totalChunks, $pubkeyCount));
}
);
} finally {
$nipBar->finish();
$io->newLine(2);
}
try {
$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>.',
@ -171,13 +219,15 @@ final class PrewarmCommand extends Command @@ -171,13 +219,15 @@ final class PrewarmCommand extends Command
$total,
$batchSize
));
$bar = $io->createProgressBar($total);
$bar = $this->createPrewarmProgressBar($io, $total, 'Kind-0 metadata');
$bar->start();
try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0MetadataForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched, $keys);
$bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? '');
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…');
}
} catch (\Throwable $e) {
$this->logger->error('app:prewarm metadata batch failed', ['exception' => $e]);
@ -215,33 +265,102 @@ final class PrewarmCommand extends Command @@ -215,33 +265,102 @@ final class PrewarmCommand extends Command
$qb->setMaxResults($maxArticles);
}
$articles = $qb->getQuery()->getResult();
$articleCount = \count($articles);
$w = 0;
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning('Comment phase stopped: comments-budget reached.');
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$eventHex = (string) ($article->getEventId() ?? '');
if ($articleCount === 0) {
$io->note('No articles in DB to scan for comment cache.');
} else {
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads');
$cBar->start();
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning('Comment phase stopped: comments-budget reached.');
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
$cBar->advance(1);
$cBar->setMessage('skip · invalid row');
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
$msg = substr($msg, 0, 53).'…';
}
$cBar->setMessage($msg);
$eventHex = (string) ($article->getEventId() ?? '');
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
}
$cBar->advance(1);
}
} finally {
$cBar->finish();
$io->newLine(2);
}
}
$io->success(sprintf('Warmed comment cache for %d of %d article(s).', $w, \count($articles)));
$io->success(sprintf('Warmed comment cache for %d of %d article(s).', $w, $articleCount));
return Command::SUCCESS;
}
private function createPrewarmProgressBar(SymfonyStyle $io, int $max, string $message = ''): ProgressBar
{
$bar = $io->createProgressBar($max);
if (method_exists($bar, 'setMinSecondsBetweenRedraws')) {
$bar->setMinSecondsBetweenRedraws(5.0);
}
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' <comment>%message%</comment> <info>%elapsed:6s%</info> ');
$bar->setMessage($message);
return $bar;
}
/**
* @param object{silent?: bool} $controller
*/
private function runWithPcntlHeartbeat(SymfonyStyle $io, int $sec, callable $work, ?object $controller = null): void
{
if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal') || !\function_exists('pcntl_alarm')) {
$work();
return;
}
$t0 = time();
$handler = function () use ($io, $t0, $sec, $controller): void {
if ($controller !== null && !empty($controller->silent)) {
return;
}
$io->writeln(sprintf(' <comment>… %ds elapsed</comment>', time() - $t0));
\pcntl_alarm((int) ceil($sec));
};
\pcntl_async_signals(true);
\pcntl_signal(\SIGALRM, $handler);
\pcntl_alarm((int) ceil($sec));
try {
$work();
} finally {
\pcntl_alarm(0);
\pcntl_signal(\SIGALRM, \SIG_DFL);
}
}
private function cancelPcntlAlarm(): void
{
if (\function_exists('pcntl_alarm')) {
@\pcntl_alarm(0);
}
}
private function disableCliExecutionTimeLimit(): void
{
@set_time_limit(0);

34
src/Controller/AuthorController.php

@ -5,8 +5,10 @@ declare(strict_types=1); @@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use App\Service\NostrClient;
use App\Service\CacheService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
use Exception;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -19,12 +21,20 @@ class AuthorController extends AbstractController @@ -19,12 +21,20 @@ class AuthorController extends AbstractController
* @throws Exception
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, NostrClient $nostrClient, CacheService $cacheService, ArticleRepository $articleRepository): Response
{
public function index(
$npub,
NostrClient $nostrClient,
CacheService $cacheService,
ArticleRepository $articleRepository,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks,
): Response {
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$author = $cacheService->getMetadata($npub);
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
// Retrieve long-form content for the author
try {
$list = $nostrClient->getLongFormContentForPubkey($npub);
@ -49,11 +59,26 @@ class AuthorController extends AbstractController @@ -49,11 +59,26 @@ class AuthorController extends AbstractController
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
$kind10133 = [];
try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
} catch (Exception) {
}
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$jumbleBase = (string) $this->getParameter('jumble_profile_users_base');
$jumbleBase = rtrim($jumbleBase, '/');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
return $this->render('pages/author.html.twig', [
'author' => $author,
'npub' => $npub,
'articles' => $articles,
'is_author_profile' => true,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]);
}
@ -65,6 +90,7 @@ class AuthorController extends AbstractController @@ -65,6 +90,7 @@ class AuthorController extends AbstractController
{
$keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]);
}
}

9
src/Controller/DefaultController.php

@ -21,8 +21,17 @@ class DefaultController extends AbstractController @@ -21,8 +21,17 @@ class DefaultController extends AbstractController
#[Route('/', name: 'home')]
public function index(): Response
{
$categoriesForFeed = [];
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$categoriesForFeed[] = [
'slug' => $slug,
'title' => $this->magazineContent->getCategoryDisplayTitle($slug),
];
}
return $this->render('home.html.twig', [
'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
'categories_for_feed' => $categoriesForFeed,
]);
}

354
src/Controller/SeoController.php

@ -0,0 +1,354 @@ @@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Sitemap, robots.txt, and Atom feeds for the magazine and each category.
*/
final class SeoController extends AbstractController
{
private const FEED_MAX_ITEMS = 100;
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly MagazineContentService $magazineContent,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
) {
}
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
public function sitemap(): Response
{
$urls = [];
$urls[] = ['loc' => $this->absoluteUrlForRoute('home'), 'lastmod' => null];
if ((bool) $this->params->get('community_articles')) {
$urls[] = ['loc' => $this->absoluteUrlForRoute('articles'), 'lastmod' => null];
}
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$urls[] = [
'loc' => $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]),
'lastmod' => null,
];
}
$articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles);
foreach ($bySlug as $article) {
$urls[] = [
'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]),
'lastmod' => $this->articleLastMod($article),
];
}
$body = '<?xml version="1.0" encoding="UTF-8"?>'
."\n"
.'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
foreach ($urls as $row) {
$body .= "\n <url>\n <loc>".$this->xmlText($row['loc']).'</loc>';
if ($row['lastmod'] instanceof \DateTimeInterface) {
$body .= "\n <lastmod>".$row['lastmod']->format('Y-m-d').'</lastmod>';
}
$body .= "\n </url>";
}
$body .= "\n</urlset>\n";
return $this->xmlResponse($body);
}
#[Route('/robots.txt', name: 'robots_txt', methods: ['GET'])]
public function robots(): Response
{
$sitemap = $this->absoluteUrlForRoute('sitemap');
$txt = "User-agent: *\nAllow: /\n\nSitemap: {$sitemap}\n";
return new Response(
$txt,
Response::HTTP_OK,
[
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
],
);
}
#[Route('/feeds/magazine.xml', name: 'feed_magazine', methods: ['GET'])]
public function feedMagazine(Request $request): Response
{
$site = (string) $this->params->get('name');
$articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles);
$list = \array_values($bySlug);
usort($list, static function (Article $a, Article $b): int {
$ca = $a->getCreatedAt();
$cb = $b->getCreatedAt();
if ($ca === null && $cb === null) {
return 0;
}
if ($ca === null) {
return 1;
}
if ($cb === null) {
return -1;
}
return $cb <=> $ca;
});
$list = \array_slice($list, 0, self::FEED_MAX_ITEMS);
$feedUrl = $this->absoluteUrlForRoute('feed_magazine');
$homeUrl = $this->absoluteUrlForRoute('home');
$selfId = 'urn:web:'.$this->urlHostId($request).':feed:magazine';
$updated = $this->newestArticleUpdate($list);
$body = $this->buildAtomFeed(
$site.': all articles',
(string) $this->params->get('description'),
$selfId,
$feedUrl,
$homeUrl,
$updated,
$request,
$list,
);
return $this->atomResponse($body);
}
#[Route('/feeds/cat/{slug}.xml', name: 'feed_category', methods: ['GET'])]
public function feedCategory(Request $request, string $slug): Response
{
if ($this->magazineIndexStore->getCategory($slug) === null) {
throw $this->createNotFoundException('Unknown category');
}
$site = (string) $this->params->get('name');
$data = $this->magazineContent->getCategoryPageData($slug);
$rawList = $data['list'] ?? [];
$catTitle = (string) ($data['category']['title'] ?? $this->magazineContent->getCategoryDisplayTitle($slug));
$summary = (string) ($data['category']['summary'] ?? '');
$list = array_values(
array_filter(
$rawList,
static function (Article $a): bool {
$s = $a->getEventStatus();
if ($s === null) {
return false;
}
return $s === EventStatusEnum::PUBLISHED || $s === EventStatusEnum::ARCHIVED;
}
)
);
if (\count($list) > self::FEED_MAX_ITEMS) {
$list = \array_slice($list, 0, self::FEED_MAX_ITEMS);
}
$feedUrl = $this->absoluteUrlForRoute('feed_category', ['slug' => $slug]);
$categoryPage = $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]);
$selfId = 'urn:web:'.$this->urlHostId($request).':feed:cat:'.rawurlencode($slug);
$title = $catTitle !== '' ? $catTitle.' — '.$site : $site;
$subtitle = $summary !== '' ? $summary : (string) $this->params->get('description');
$updated = $this->newestArticleUpdate($list);
$body = $this->buildAtomFeed(
$title,
$subtitle,
$selfId,
$feedUrl,
$categoryPage,
$updated,
$request,
$list,
);
return $this->atomResponse($body);
}
private function absoluteUrlForRoute(string $name, array $params = []): string
{
return $this->generateUrl($name, $params, UrlGeneratorInterface::ABSOLUTE_URL);
}
private function urlHostId(Request $request): string
{
$h = $request->getHost();
return preg_replace('/[^a-zA-Z0-9.\\-]+/', '-', $h) ?? 'site';
}
/**
* @param list<Article> $list
*/
private function buildAtomFeed(
string $title,
string $subtitle,
string $id,
string $selfUrl,
string $alternateHtmlUrl,
\DateTimeImmutable $updated,
Request $request,
array $list,
): string {
$xml = '<?xml version="1.0" encoding="utf-8"?>'
."\n"
.'<feed xmlns="http://www.w3.org/2005/Atom">'
."\n <title>".$this->xmlText($title)."</title>\n <subtitle>".$this->xmlText($subtitle)."</subtitle>";
$xml .= "\n <id>".$this->xmlText($id).'</id>';
$xml .= "\n <link href=\"".$this->xmlAttr($selfUrl)."\" rel=\"self\" type=\"application/atom+xml\"/>";
$xml .= "\n <link href=\"".$this->xmlAttr($alternateHtmlUrl)."\" rel=\"alternate\" type=\"text/html\"/>";
$xml .= "\n <updated>".$this->xmlText($updated->format('c')).'</updated>';
$authorName = (string) $this->params->get('name');
$xml .= "\n <author><name>".$this->xmlText($authorName)."</name></author>\n <generator uri=\"https://github.com/decent-newsroom/unfold\" version=\"1\">unfold</generator>";
foreach ($list as $article) {
$xml .= $this->atomEntryForArticle($request, $article);
}
$xml .= "\n</feed>\n";
return $xml;
}
private function atomEntryForArticle(Request $request, Article $article): string
{
$slug = \trim((string) $article->getSlug());
if ($slug === '') {
return '';
}
$permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]);
$title = (string) ($article->getTitle() ?? 'Untitled');
$tArticle = $this->articleLastMod($article);
$sum = (string) ($article->getSummary() ?? '');
if ($sum === '' && $article->getContent() !== null) {
$plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? '';
$sum = (string) mb_substr($plain, 0, 500);
}
$eId = (string) ($article->getEventId() ?? '');
if ($eId === '') {
$eId = (string) ($article->getId() ?? 'item');
}
$entryId = 'urn:web:'.$this->urlHostId($request).":article:{$eId}";
$pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle;
$out = "\n <entry>";
$out .= "\n <title>".$this->xmlText($title)."</title>";
$out .= "\n <link href=\"".$this->xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>";
$out .= "\n <id>".$this->xmlText($entryId).'</id>';
$out .= "\n <updated>".$this->xmlText($tArticle->format('c'))."</updated>\n <published>".$this->xmlText($pub->format('c')).'</published>';
$out .= "\n <summary type=\"text\">".$this->xmlText($this->oneLine($sum))."</summary>";
$out .= "\n </entry>";
return $out;
}
private function oneLine(string $s): string
{
return trim(preg_replace("/[\r\n]+/", ' ', $s) ?? '');
}
/**
* @param list<Article> $articles
* @return array<string, Article>
*/
private function dedupeArticlesByLatestRevision(array $articles): array
{
$bySlug = [];
foreach ($articles as $article) {
$slug = \trim((string) $article->getSlug());
if ($slug === '') {
continue;
}
$c = $article->getCreatedAt();
if (!isset($bySlug[$slug])) {
$bySlug[$slug] = $article;
continue;
}
$prev = $bySlug[$slug]->getCreatedAt();
if ($c !== null && (null === $prev || $c > $prev)) {
$bySlug[$slug] = $article;
}
}
return $bySlug;
}
/**
* @param list<Article> $list
*/
private function newestArticleUpdate(array $list): \DateTimeImmutable
{
$t = new \DateTimeImmutable('@0');
foreach ($list as $a) {
$m = $this->articleLastMod($a);
if ($m > $t) {
$t = $m;
}
}
if ((int) $t->format('U') === 0) {
return new \DateTimeImmutable();
}
return $t;
}
private function articleLastMod(Article $a): \DateTimeImmutable
{
$p = $a->getPublishedAt();
$c = $a->getCreatedAt() ?? $p;
if ($p !== null && $c !== null) {
return $p > $c ? $p : $c;
}
return $p ?? $c ?? new \DateTimeImmutable();
}
private function xmlText(string $s): string
{
return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8');
}
private function xmlAttr(string $s): string
{
return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8');
}
private function xmlResponse(string $body): Response
{
return new Response(
$body,
Response::HTTP_OK,
[
'Content-Type' => 'application/xml; charset=UTF-8',
'Cache-Control' => 'public, max-age=600',
],
);
}
private function atomResponse(string $body): Response
{
return new Response(
$body,
Response::HTTP_OK,
[
'Content-Type' => 'application/atom+xml; charset=UTF-8',
'Cache-Control' => 'public, max-age=300',
],
);
}
}

1
src/Enum/KindsEnum.php

@ -20,5 +20,6 @@ enum KindsEnum: int @@ -20,5 +20,6 @@ enum KindsEnum: int
case ZAP = 9735; // NIP-57, Zaps
case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case PAYMENT_TARGETS = 10133; // NIP-A3, payto: payment targets (replaceable)
case APP_DATA = 30078; // NIP-78, Arbitrary custom app data
}

21
src/Repository/ArticleRepository.php

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
namespace App\Repository;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry;
@ -143,4 +143,23 @@ class ArticleRepository extends ServiceEntityRepository @@ -143,4 +143,23 @@ class ArticleRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
/**
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug);
* callers should dedupe by slug if URLs are slug-only.
*
* @return list<Article>
*/
public function findPublishedForSyndication(int $limit = 5000): array
{
return $this->createQueryBuilder('a')
->where('a.slug IS NOT NULL')
->andWhere("TRIM(a.slug) != ''")
->andWhere('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

82
src/Service/CacheService.php

@ -24,20 +24,52 @@ readonly class CacheService @@ -24,20 +24,52 @@ readonly class CacheService
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass
{
return $this->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<list<string>>}
*/
public function getMetadataBundle(string $npub): array
{
$aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
$cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
$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 json_decode($meta->content);
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'] : [],
];
}
// Legacy: cache stored only the decoded content object
if ($cached instanceof \stdClass) {
return ['content' => $cached, 'kind0_tags' => []];
}
} catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e;
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
@ -47,8 +79,50 @@ readonly class CacheService @@ -47,8 +79,50 @@ readonly class CacheService
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $content;
return [
'content' => $content,
'kind0_tags' => [],
];
}
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return [
'content' => $content,
'kind0_tags' => [],
];
}
/**
* @return list<list<string>>
*/
private static function normalizeEventTagsList(mixed $tags): array
{
if (!\is_array($tags)) {
return [];
}
$out = [];
foreach ($tags as $row) {
if (!\is_array($row) && !\is_object($row)) {
continue;
}
$seq = \is_object($row) ? get_object_vars($row) : $row;
if ($seq === []) {
continue;
}
$r = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
)
);
if ($r !== [] && (string) ($r[0] ?? '') !== '') {
$out[] = $r;
}
}
return $out;
}
/**

48
src/Service/MagazineContentService.php

@ -95,6 +95,54 @@ final class MagazineContentService @@ -95,6 +95,54 @@ final class MagazineContentService
return $age > self::ROOT_REVALIDATE_SECONDS;
}
/**
* Category path slugs from the persisted root index (third segment of each category `a` tag).
*
* @return list<string>
*/
public function getCategorySlugsFromStore(): array
{
$tags = $this->getHomeCategoryAIndexTagsFromStoreOnly();
$out = [];
foreach ($tags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
continue;
}
$parts = explode(':', $coord, 3);
if (\count($parts) < 3) {
continue;
}
$slug = trim((string) $parts[2]);
if ($slug !== '') {
$out[] = $slug;
}
}
return array_values(array_unique($out));
}
/**
* Title from cached category index event tags, or the slug when missing.
*/
public function getCategoryDisplayTitle(string $slug): string
{
if ($slug === '') {
return '';
}
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
return $slug;
}
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) {
return (string) $tag[1];
}
}
return $slug;
}
/**
* @return array{list: list<Article>, category: array{title: string, summary: string}}
*/

14
src/Service/MagazineIndexStore.php

@ -40,7 +40,7 @@ final class MagazineIndexStore @@ -40,7 +40,7 @@ final class MagazineIndexStore
if ($slug === '') {
return null;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
$item = $this->pool->getItem($this->categoryKey($slug));
if (!$item->isHit()) {
return null;
}
@ -67,7 +67,7 @@ final class MagazineIndexStore @@ -67,7 +67,7 @@ final class MagazineIndexStore
if ($slug === '') {
return;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
$item = $this->pool->getItem($this->categoryKey($slug));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
@ -83,7 +83,7 @@ final class MagazineIndexStore @@ -83,7 +83,7 @@ final class MagazineIndexStore
if ($slug === '') {
return;
}
$this->pool->deleteItem(self::CAT_PREFIX.$slug);
$this->pool->deleteItem($this->categoryKey($slug));
}
/**
@ -101,6 +101,14 @@ final class MagazineIndexStore @@ -101,6 +101,14 @@ final class MagazineIndexStore
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
}
/**
* 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);
}
private function unwrap(mixed $value): ?Event
{
if (!\is_string($value) || $value === '') {

21
src/Service/MagazineRefresher.php

@ -29,8 +29,11 @@ final class MagazineRefresher @@ -29,8 +29,11 @@ final class MagazineRefresher
/**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
*
* @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count), `category_fetched` (step, total_steps, slug)
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = []): void
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{
$budgetSeconds = max(1, min(30, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
@ -45,8 +48,10 @@ final class MagazineRefresher @@ -45,8 +48,10 @@ final class MagazineRefresher
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
$onProgress?->__invoke('before_root', []);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) {
$onProgress?->__invoke('aborted', ['reason' => 'no_root']);
$this->logger->warning(sprintf(
'MagazineRefresher: root index not returned (tried from %s)',
$relayLabel
@ -61,6 +66,13 @@ final class MagazineRefresher @@ -61,6 +66,13 @@ final class MagazineRefresher
$this->store->putRoot($npub, $dTag, $root);
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs);
$totalSteps = 1 + \count($slugs);
$onProgress?->__invoke('after_root', [
'total_steps' => $totalSteps,
'step' => 1,
'slug_count' => \count($slugs),
]);
$step = 1;
foreach ($slugs as $slug) {
if (microtime(true) >= $deadline) {
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [
@ -83,6 +95,13 @@ final class MagazineRefresher @@ -83,6 +95,13 @@ final class MagazineRefresher
'message' => $e->getMessage(),
'relay' => $defaultRelay,
]);
} finally {
++$step;
$onProgress?->__invoke('category_fetched', [
'step' => $step,
'total_steps' => $totalSteps,
'slug' => $slug,
]);
}
}

46
src/Service/NostrClient.php

@ -431,6 +431,7 @@ class NostrClient @@ -431,6 +431,7 @@ class NostrClient
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
* @param (callable(int, int, int): void)|null $afterChunk 1-based index, total chunks, pubkeys in chunk
* @param list<string> $authorPubkeyHex
* @return list<stdClass> Deduplicated by event `id` (highest {@see created_at} kept)
*/
@ -439,6 +440,7 @@ class NostrClient @@ -439,6 +440,7 @@ class NostrClient
int $since,
int $until,
int $authorsPerRequest = 40,
?callable $afterChunk = null,
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
@ -449,7 +451,9 @@ class NostrClient @@ -449,7 +451,9 @@ class NostrClient
}
$authorsPerRequest = max(1, min(100, $authorsPerRequest));
$byId = [];
foreach (array_chunk($authorPubkeyHex, $authorsPerRequest) as $chunk) {
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
$numChunks = \count($chunks);
foreach ($chunks as $i => $chunk) {
$request = $this->createNostrRequest(
kinds: [KindsEnum::DELETION_REQUEST],
filters: [
@ -468,6 +472,9 @@ class NostrClient @@ -468,6 +472,9 @@ class NostrClient
'raw_events' => \count($events),
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
if ($afterChunk !== null) {
$afterChunk(1 + (int) $i, $numChunks, \count($chunk));
}
foreach ($events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
@ -520,6 +527,43 @@ class NostrClient @@ -520,6 +527,43 @@ class NostrClient
return $events[0];
}
/**
* NIP-A3 kind 10133: payment target events (replaceable) with `["payto", type, authority, ...]` tags.
*
* @return list<object>
*/
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
try {
$request = $this->createNostrRequest(
kinds: [KindsEnum::PAYMENT_TARGETS],
filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))],
relaySet: $relaySet
);
$events = $this->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
} catch (\Throwable $e) {
$this->logger->warning('nostr.kind10133.fetch_failed', [
'npub' => $npub,
'relays' => $relaysTriedStr,
'error' => $e->getMessage(),
]);
return [];
}
if (!\is_array($events) || $events === []) {
return [];
}
usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0));
return array_values($events);
}
public function getNpubLongForm($npub): void
{
$subscription = new Subscription();

201
src/Service/ProfileIdentityLinksBuilder.php

@ -0,0 +1,201 @@ @@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Website and NIP-05 links from kind-0 JSON and kind-0 tags, normalized and deduplicated.
*/
final class ProfileIdentityLinksBuilder
{
/**
* @param list<list<string>> $kind0Tags
*
* @return list<array{label: string, href: string}>
*/
public function buildWebsites(object $content, array $kind0Tags): array
{
$raw = [];
foreach ($this->stringsFromJsonField($content, 'website') as $s) {
$raw[] = $s;
}
foreach ($this->stringsFromJsonField($content, 'websites') as $s) {
$raw[] = $s;
}
foreach (self::tagValuesForNames($kind0Tags, ['url', 'website', 'web']) as $s) {
$raw[] = $s;
}
$out = [];
$seen = [];
foreach ($raw as $u) {
$u = trim($u);
if ($u === '') {
continue;
}
$href = $this->normalizeHttpUrl($u);
if ($href === null) {
continue;
}
$k = self::urlDedupKey($href);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'label' => $this->displayLabelForHttpUrl($href),
'href' => $href,
];
}
usort($out, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label']));
return $out;
}
/**
* @param list<list<string>> $kind0Tags
*
* @return list<array{label: string, href: string}>
*/
public function buildNip05(object $content, array $kind0Tags): array
{
$raw = [];
foreach ($this->stringsFromJsonField($content, 'nip05') as $s) {
$raw[] = $s;
}
foreach (self::tagValuesForNames($kind0Tags, ['nip05']) as $s) {
$raw[] = $s;
}
$out = [];
$seen = [];
foreach ($raw as $id) {
$id = trim(strtolower($id));
if ($id === '' || !str_contains($id, '@')) {
continue;
}
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$parts = explode('@', $id, 2);
$local = $parts[0] ?? '';
$domain = $parts[1] ?? '';
if ($local === '' || $domain === '' || str_contains($domain, ' ')) {
continue;
}
$href = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local);
$out[] = [
'label' => $id,
'href' => $href,
];
}
usort($out, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label']));
return $out;
}
/**
* @return list<string>
*/
private function stringsFromJsonField(object $o, string $key): array
{
if (!isset($o->{$key})) {
return [];
}
$v = $o->{$key};
if (\is_string($v)) {
return [trim($v)];
}
if (!\is_array($v)) {
return [];
}
$out = [];
foreach ($v as $x) {
if (\is_string($x) && trim($x) !== '') {
$out[] = trim($x);
}
}
return $out;
}
/**
* @param list<list<string>> $tags
* @param list<string> $tagNames lowercased
* @return list<string>
*/
private static function tagValuesForNames(array $tags, array $tagNames): array
{
$want = array_fill_keys(array_map(static fn (string $s): string => strtolower($s), $tagNames), true);
$out = [];
foreach ($tags as $t) {
if (!isset($t[0], $t[1])) {
continue;
}
$name = strtolower((string) $t[0]);
if (!isset($want[$name])) {
continue;
}
$v = trim((string) $t[1]);
if ($v !== '') {
$out[] = $v;
}
}
return $out;
}
private function normalizeHttpUrl(string $url): ?string
{
$u = trim($url);
if ($u === '') {
return null;
}
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $u)) {
$u = 'https://'.$u;
}
if (!str_starts_with(strtolower($u), 'http://') && !str_starts_with(strtolower($u), 'https://')) {
return null;
}
return $u;
}
private static function urlDedupKey(string $href): string
{
$p = @parse_url($href);
if (!\is_array($p)) {
return strtolower($href);
}
$host = strtolower((string) ($p['host'] ?? ''));
$path = (string) ($p['path'] ?? '');
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
return $host.$path.($p['query'] ?? '');
}
private function displayLabelForHttpUrl(string $href): string
{
$p = @parse_url($href);
if (\is_array($p) && !empty($p['host'])) {
$host = (string) $p['host'];
$path = (string) ($p['path'] ?? '');
if ($path === '' || $path === '/') {
return $host;
}
if (strlen($path) > 32) {
return $host.$path;
}
return $host.$path;
}
if (strlen($href) > 48) {
return substr($href, 0, 32).'…';
}
return $href;
}
}

419
src/Service/ProfilePaymentLinksBuilder.php

@ -0,0 +1,419 @@ @@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
/**
* Lightning (lud06 / lud16) and NIP-A3 payto: kind-0 JSON, kind-0 tags, and kind 10133 events.
*
* NIP-A3: `["payto", "<type>", "<authority>", ...]` → `payto://<type>/<authority>…` (RFC 8905 family).
*
* NIP-A3: kind 10133 replaceable payment target events; tag `["payto", type, authority, ...]`.
* @see https://github.com/CodyTseng/jumble/blob/master/src/lib/lightning.ts getLightningAddressFromProfile
*/
final class ProfilePaymentLinksBuilder
{
public const TYPE_LIGHTNING_ADDRESS = 'lightning_address';
public const TYPE_LNURL_PAY = 'lnurl_pay';
public const TYPE_PAYTO = 'payto';
/**
* @param list<list<string>> $kind0Tags
* @param list<string> $extraPaytoUris from kind 10133
*
* @return list<array{type: string, type_label: string, label: string, href: string, sort: int}>
*/
public function buildPaymentRows(object $content, array $kind0Tags, array $extraPaytoUris = []): array
{
$a = $this->stringField($content, 'lud16');
$b = $this->stringField($content, 'lud06');
$resolved = $this->resolveLud16Lud06($a, $b);
$rows = [];
$seen = [];
if ($resolved['lightning_address'] !== null) {
$addr = $resolved['lightning_address'];
$norm = 'la:'.strtolower($addr);
if (!isset($seen[$norm])) {
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LIGHTNING_ADDRESS,
'type_label' => 'Lightning address',
'label' => $addr,
'href' => 'lightning:'.$addr,
'sort' => 0,
];
}
}
if ($resolved['lnurl_pay'] !== null) {
$ln = $resolved['lnurl_pay'];
$norm = 'lnurl:'.strtolower($ln);
if (!isset($seen[$norm])) {
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LNURL_PAY,
'type_label' => 'LNURL Pay',
'label' => $this->shortenLnurl($ln),
'href' => 'lightning:'.$ln,
'sort' => 1,
];
}
}
$allPayto = array_merge(
$this->paytoUrisFromJsonObject($content),
$this->paytoUrisFromNipA3StyleTags($kind0Tags),
$extraPaytoUris
);
foreach ($allPayto as $uri) {
$uri = self::trimPaytoString($uri);
if ($uri === null) {
continue;
}
if (!self::isPaytoOrLegacyPaytoScheme($uri)) {
continue;
}
$canon = self::normalizePaytoUriForDedup($uri);
if (isset($seen[$canon])) {
continue;
}
$seen[$canon] = true;
$rows[] = [
'type' => self::TYPE_PAYTO,
'type_label' => 'Payto',
'label' => $this->labelForPaytoUri($uri),
'href' => $uri,
'sort' => 2,
];
}
$rows = array_values(
array_filter(
$rows,
static fn (array $r): bool => self::isAllowedPaymentHref($r['href'])
)
);
usort(
$rows,
static function (array $x, array $y): int {
if ($x['sort'] !== $y['sort']) {
return $x['sort'] <=> $y['sort'];
}
return strcasecmp($x['label'], $y['label']);
}
);
return $rows;
}
/**
* @param list<object> $kind10133Events
*
* @return list<string>
*/
public function collectPaytoUrisFromNipA3Kind10133Events(array $kind10133Events): array
{
$out = [];
foreach ($kind10133Events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::PAYMENT_TARGETS->value) {
continue;
}
$tags = self::normalizeTagsArray($ev->tags ?? null);
foreach ($tags as $t) {
$u = $this->buildPaytoUriFromNipA3Tag($t);
if ($u !== null) {
$out[] = $u;
}
}
}
return $out;
}
/**
* NIP-A3: `["payto", type, authority, ...]` or legacy `["payto", "payto:..."]` / `["payto", "payto://..."]`.
*
* @param list<string> $t
*/
private function buildPaytoUriFromNipA3Tag(array $t): ?string
{
if (!isset($t[0]) || strtolower((string) $t[0]) !== 'payto') {
return null;
}
if (\count($t) >= 3) {
$type = strtolower(trim((string) $t[1]));
if ($type === '' || 1 !== preg_match('/^[a-z0-9-]+$/', $type)) {
return null;
}
$rawParts = [];
for ($i = 2; $i < \count($t); ++$i) {
$p = trim((string) $t[$i]);
if ($p !== '') {
$rawParts[] = $p;
}
}
if ($rawParts === []) {
return null;
}
$segs = array_map(static fn (string $p): string => rawurlencode($p), $rawParts);
return 'payto://'.$type.'/'.implode('/', $segs);
}
if (isset($t[1])) {
$s = self::trimPaytoString((string) $t[1]);
if ($s !== null && self::isPaytoOrLegacyPaytoScheme($s)) {
return $s;
}
}
return null;
}
/**
* @param list<list<string>> $tags
*
* @return list<string>
*/
private function paytoUrisFromNipA3StyleTags(array $tags): array
{
$out = [];
foreach ($tags as $t) {
$u = $this->buildPaytoUriFromNipA3Tag($t);
if ($u !== null) {
$out[] = $u;
}
}
return $out;
}
private static function normalizeTagsArray(mixed $tags): array
{
if (!\is_array($tags)) {
return [];
}
$out = [];
foreach ($tags as $row) {
if (!\is_array($row) && !\is_object($row)) {
continue;
}
$seq = \is_object($row) ? get_object_vars($row) : $row;
if ($seq === []) {
continue;
}
$r = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
)
);
if ($r !== []) {
$out[] = $r;
}
}
return $out;
}
private static function isPaytoOrLegacyPaytoScheme(string $s): bool
{
$l = strtolower($s);
return str_starts_with($l, 'payto:');
}
/**
* Deduplication key (lowercase; paths may be lossy for odd encodings, acceptable for same-target merge).
*/
private static function normalizePaytoUriForDedup(string $uri): string
{
if (str_starts_with(strtolower($uri), 'payto://')) {
if (1 === preg_match('#^payto://([a-z0-9-]+)/(.+)$#i', $uri, $m)) {
return 'payto://'.strtolower($m[1]).'/'.strtolower($m[2]);
}
}
return strtolower($uri);
}
private static function trimPaytoString(string $s): ?string
{
$s = trim($s);
return $s === '' ? null : $s;
}
private function labelForPaytoUri(string $u): string
{
if (1 === preg_match('#^payto://([a-z0-9-]+)/(.+)$#i', $u, $m)) {
$t = $this->stylizePaytoTypeName($m[1]);
$path = rawurldecode((string) $m[2]);
if (str_contains($path, '/')) {
$a = (string) strstr($path, '/', true);
} else {
$a = $path;
}
if (strlen($a) > 42) {
$a = substr($a, 0, 20).'…'.substr($a, -10);
}
return $t.' · '.$a;
}
if (strlen($u) > 64) {
return substr($u, 0, 36).'…';
}
return $u;
}
private function stylizePaytoTypeName(string $type): string
{
$type = strtolower($type);
return match ($type) {
'bitcoin' => 'Bitcoin',
'lightning' => 'Lightning',
'ethereum' => 'Ethereum',
'nano' => 'Nano',
'monero' => 'Monero',
'cashme' => 'Cash App',
'revolut' => 'Revolut',
'venmo' => 'Venmo',
default => $type,
};
}
private static function isAllowedPaymentHref(string $href): bool
{
if ($href === '') {
return false;
}
$h = strtolower($href);
return str_starts_with($h, 'lightning:') || str_starts_with($h, 'payto:');
}
/**
* @return array{lightning_address: ?string, lnurl_pay: ?string}
*/
private function resolveLud16Lud06(?string $a, ?string $b): array
{
$lightningAddress = null;
if ($a !== null && $this->isEmail($a)) {
$lightningAddress = $a;
} elseif ($b !== null && $this->isEmail($b)) {
$lightningAddress = $b;
}
$lnurl = null;
if ($a !== null && $this->isLnurlBech32($a) && $a !== $lightningAddress) {
$lnurl = $a;
} elseif ($b !== null && $this->isLnurlBech32($b) && $b !== $lightningAddress) {
$lnurl = $b;
}
return [
'lightning_address' => $lightningAddress,
'lnurl_pay' => $lnurl,
];
}
private function stringField(object $o, string $key): ?string
{
if (!isset($o->{$key})) {
return null;
}
$v = $o->{$key};
if (!\is_string($v)) {
return null;
}
$v = trim($v);
if ($v === '') {
return null;
}
return $v;
}
private function isEmail(string $s): bool
{
return 1 === preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $s);
}
private function isLnurlBech32(string $s): bool
{
$lower = strtolower($s);
return str_starts_with($lower, 'lnurl');
}
private function shortenLnurl(string $lnurl): string
{
if (strlen($lnurl) <= 24) {
return $lnurl;
}
return substr($lnurl, 0, 10).'…'.substr($lnurl, -8);
}
/**
* JSON: strings (full `payto:` / `payto://` URI), or objects with `type`+`authority` (NIP-A3-style).
*
* @return list<string>
*/
private function paytoUrisFromJsonObject(object $metadata): array
{
$out = [];
if (!isset($metadata->payto)) {
return $out;
}
$p = $metadata->payto;
if (\is_string($p)) {
if (trim($p) !== '') {
$out[] = $p;
}
return $out;
}
if (!\is_array($p)) {
return $out;
}
foreach ($p as $item) {
if (\is_string($item) && trim($item) !== '') {
$out[] = $item;
continue;
}
if (!\is_object($item) && !\is_array($item)) {
continue;
}
$o = (object) $item;
if (isset($o->type, $o->authority) && \is_string($o->type) && \is_string($o->authority)) {
$t = strtolower(trim($o->type));
$auth = trim($o->authority);
if ($t !== '' && $auth !== '' && 1 === preg_match('/^[a-z0-9-]+$/', $t)) {
$out[] = 'payto://'.$t.'/'.rawurlencode($auth);
continue;
}
}
foreach (['uri', 'url', 'payto', 'href', 'value'] as $k) {
if (isset($o->{$k}) && \is_string($o->{$k}) && trim($o->{$k}) !== '') {
$out[] = trim($o->{$k});
break;
}
}
}
return $out;
}
}

20
templates/home.html.twig

@ -1,9 +1,17 @@ @@ -1,9 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}{{ website_name }}{% endblock %}
{% block meta_description %}
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
{% block magazine_sync_page %}home{% endblock %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<link rel="canonical" href="{{ url('home') }}">
<link rel="alternate" type="application/atom+xml" title="{{ website_name|e('html_attr') }} — all articles" href="{{ url('feed_magazine') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url('home') }}">
<meta property="og:title" content="{{ website_name|e('html_attr') }}">
@ -20,6 +28,18 @@ @@ -20,6 +28,18 @@
{% endblock %}
{% block body %}
<div class="home-subscribe" aria-label="Sitemap and syndication">
<h2 class="home-subscribe__title">Sitemap and feeds</h2>
<p class="home-subscribe__hint">For search engines and feed readers. Atom is supported by most clients.</p>
<div class="home-subscribe__actions">
<a class="btn btn-secondary" href="{{ path('sitemap') }}">Sitemap (XML)</a>
<a class="btn btn-secondary" href="{{ path('robots_txt') }}">Robots</a>
<a class="btn btn-secondary" href="{{ path('feed_magazine') }}">Atom — all articles</a>
{% for c in categories_for_feed %}
<a class="btn btn-secondary" href="{{ path('feed_category', {slug: c.slug}) }}">Atom — {{ c.title }}</a>
{% endfor %}
</div>
</div>
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>

40
templates/pages/author.html.twig

@ -17,12 +17,52 @@ @@ -17,12 +17,52 @@
{% endif %}
<h1 class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% for row in profile_websites %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05">
{% for row in profile_nip05 %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">NIP-05</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
{% for row in profile_payment_links %}
<li class="author-profile__payment">
<span class="author-profile__payment-type">{{ row.type_label }}</span>
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="author-profile__about">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a>
</p>
{% endif %}
<hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>

Loading…
Cancel
Save