Compare commits
No commits in common. '889dccc3f90d696aa4011322ad822a596d173541' and '0094939adcd19140b179a23fb0b32b040cb5bd0c' have entirely different histories.
889dccc3f9
...
0094939adc
17 changed files with 36 additions and 1544 deletions
@ -1,354 +0,0 @@ |
|||||||
<?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,201 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,419 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
Loading…
Reference in new issue