Browse Source

bug-fixes

redo nip05 rendering and validation
imwald
Silberengel 5 days ago
parent
commit
2b31b5d1de
  1. 5
      assets/controllers/progress_bar_controller.js
  2. 63
      assets/styles/app.css
  3. 34
      assets/styles/layout.css
  4. 3
      config/services.yaml
  5. 104
      src/Command/PrewarmCommand.php
  6. 15
      src/Controller/AuthorController.php
  7. 3
      src/Controller/FeaturedAuthorsController.php
  8. 4
      src/Service/MagazineRefresher.php
  9. 214
      src/Service/Nip05VerificationService.php
  10. 37
      src/Service/ProfileIdentityLinksBuilder.php
  11. 119
      src/Service/ProfilePaymentLinksBuilder.php
  12. 1
      templates/pages/author.html.twig
  13. 4
      templates/pages/featured_authors.html.twig
  14. 35
      templates/partial/author_profile_header.html.twig

5
assets/controllers/progress_bar_controller.js

@ -43,6 +43,8 @@ export default class extends Controller { @@ -43,6 +43,8 @@ export default class extends Controller {
return;
}
this.barTarget.classList.add('pb-indeterminate');
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '100%';
const finish = () => {
this.completeToDone();
};
@ -136,7 +138,8 @@ export default class extends Controller { @@ -136,7 +138,8 @@ export default class extends Controller {
}
sessionStorage.setItem(STORAGE_KEY, '1');
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '0';
this.barTarget.classList.add('pb-indeterminate');
/* Full-width track; motion is the ::before sweep in CSS (avoids keyframed width 20%↔55%). */
this.barTarget.style.width = '100%';
}
}

63
assets/styles/app.css

@ -536,7 +536,8 @@ footer a { @@ -536,7 +536,8 @@ footer a {
.author-profile__header-meta {
margin-top: 0.5rem;
max-width: 28rem;
max-width: min(100%, 40rem);
width: 100%;
margin-left: auto;
margin-right: auto;
text-align: left;
@ -548,18 +549,19 @@ footer a { @@ -548,18 +549,19 @@ footer a {
padding: 0;
}
.author-profile__identity-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
.author-profile__meta-line,
.author-profile__identity-row,
.author-profile__payment {
display: grid;
grid-template-columns: 7.5rem minmax(0, 1fr);
column-gap: 0.5rem;
align-items: center;
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;
@ -568,9 +570,36 @@ footer a { @@ -568,9 +570,36 @@ footer a {
opacity: 0.75;
}
.author-profile__identity-link {
word-break: break-all;
.author-profile__meta-value,
.author-profile__identity-link,
.author-profile__payment-link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.author-profile__nip05-value {
display: flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
overflow: hidden;
}
.author-profile__nip05-value .author-profile__identity-link {
flex: 1 1 0;
min-width: 0;
word-break: normal;
}
.author-profile__nip05-verified {
color: #2e7d32;
font-size: 0.88em;
font-weight: 600;
line-height: 1;
opacity: 0.85;
user-select: none;
}
.author-profile__payments {
@ -581,18 +610,7 @@ footer a { @@ -581,18 +610,7 @@ footer a {
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;
@ -601,11 +619,6 @@ footer a { @@ -601,11 +619,6 @@ footer a {
opacity: 0.75;
}
.author-profile__payment-link {
word-break: break-all;
min-width: 0;
}
.author-profile__jumble {
margin: 1rem 0 0;
text-align: center;

34
assets/styles/layout.css

@ -88,25 +88,41 @@ header { @@ -88,25 +88,41 @@ header {
background: var(--color-primary);
transition: width 0.4s ease;
z-index: 1000;
overflow: hidden;
pointer-events: none;
}
/* In-flight navigation: loops until the next page fires `load`, then the bar completes. */
/*
* In-flight navigation: a full-width track with a short segment that sweeps left right
* (do not keyframe the track width: 20% / 55% / 28% read as a half-screen rubber band).
*/
#progress-bar.pb-indeterminate {
transition: none;
animation: pb-indeterminate 0.9s ease-in-out infinite;
/* Tinted track: solid fill comes from ::before while loading */
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
animation: none;
}
@keyframes pb-indeterminate {
0% {
width: 20%;
}
#progress-bar.pb-indeterminate::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 35%;
height: 100%;
background: var(--color-primary);
border-radius: 0 2px 2px 0;
animation: pb-sweep 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
50% {
width: 55%;
@keyframes pb-sweep {
0% {
transform: translateX(-100%);
}
100% {
width: 28%;
/* Move one segment width past the right edge of the 100% track */
transform: translateX(calc(100% / 0.35 + 100%));
}
}

3
config/services.yaml

@ -54,3 +54,6 @@ services: @@ -54,3 +54,6 @@ services:
App\Service\CacheService:
arguments:
$appCache: '@cache.app'
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'

104
src/Command/PrewarmCommand.php

@ -6,13 +6,16 @@ namespace App\Command; @@ -6,13 +6,16 @@ namespace App\Command;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CacheService;
use App\Service\FeaturedAuthorSync;
use App\Service\MagazineContentService;
use App\Service\Nip05VerificationService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Console\Attribute\AsCommand;
@ -32,7 +35,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -32,7 +35,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
*/
#[AsCommand(
name: 'app:prewarm',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, and comment caches',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05 verification cache, and comment caches',
)]
final class PrewarmCommand extends Command
{
@ -47,6 +50,9 @@ final class PrewarmCommand extends Command @@ -47,6 +50,9 @@ final class PrewarmCommand extends Command
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly FeaturedAuthorSync $featuredAuthorSync,
private readonly Nip05VerificationService $nip05Verification,
private readonly ProfileIdentityLinksBuilder $profileIdentityLinks,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
) {
parent::__construct();
}
@ -89,6 +95,23 @@ final class PrewarmCommand extends Command @@ -89,6 +95,23 @@ final class PrewarmCommand extends Command
} elseif ($phase === 'after_root') {
$hb->silent = true;
$this->cancelPcntlAlarm();
$planned = $p['slugs'] ?? null;
if (!\is_array($planned)) {
$planned = [];
}
if ($planned === []) {
$io->writeln(' <comment>Magazine root has no child <info>a</info> tag categories; only the root index was stored.</comment>');
} else {
$n = \count($planned);
$io->writeln(sprintf(' <comment>Magazine child categories in root</comment> <info>(%d)</info><comment>:</comment>', $n));
foreach ($planned as $slug) {
$s = (string) $slug;
if (strlen($s) > 120) {
$s = substr($s, 0, 117).'…';
}
$io->writeln(sprintf(' · <info>%s</info>', $s));
}
}
$bar = $this->createPrewarmProgressBar(
$io,
max(1, (int) ($p['total_steps'] ?? 1)),
@ -99,10 +122,25 @@ final class PrewarmCommand extends Command @@ -99,10 +122,25 @@ final class PrewarmCommand extends Command
} elseif ($phase === 'category_fetched' && $bar !== null) {
$bar->advance(1);
$slug = (string) ($p['slug'] ?? '');
if (strlen($slug) > 70) {
$slug = substr($slug, 0, 67).'…';
$tSlug = $slug;
if (strlen($tSlug) > 70) {
$tSlug = substr($tSlug, 0, 67).'…';
}
$bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category');
if ($tSlug !== '') {
$step = (int) ($p['step'] ?? 0);
$tot = (int) ($p['total_steps'] ?? 0);
if ($tot > 0) {
$io->writeln(sprintf(
' <info>[%d/%d]</info> <comment>Fetched category index</comment><info>%s</info>',
$step,
$tot,
$tSlug
));
} else {
$io->writeln(sprintf(' <comment>Fetched category index</comment><info>%s</info>', $tSlug));
}
}
$bar->setMessage($slug !== '' ? 'Category: '.$slug : 'Category');
}
});
}, $hb);
@ -230,12 +268,25 @@ final class PrewarmCommand extends Command @@ -230,12 +268,25 @@ final class PrewarmCommand extends Command
if ($limit > 0) {
$pubkeys = \array_slice($pubkeys, 0, $limit);
}
$toWarm = [];
foreach ($pubkeys as $pubkey) {
if (strlen($pubkey) === 64) {
$toWarm[] = $pubkey;
$pubkeysSeen = [];
foreach ($pubkeys as $pk) {
if (!\is_string($pk) || 64 !== \strlen($pk)) {
continue;
}
$h = strtolower($pk);
if (ctype_xdigit($h) && !isset($pubkeysSeen[$h])) {
$pubkeysSeen[$h] = true;
}
}
$pubkeys = array_keys($pubkeysSeen);
foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
$hx = strtolower($fa->getPubkeyHex());
if (64 === \strlen($hx) && ctype_xdigit($hx) && !isset($pubkeysSeen[$hx])) {
$pubkeys[] = $hx;
$pubkeysSeen[$hx] = true;
}
}
$toWarm = $pubkeys;
$total = \count($toWarm);
$n = 0;
if ($total === 0) {
@ -269,6 +320,43 @@ final class PrewarmCommand extends Command @@ -269,6 +320,43 @@ final class PrewarmCommand extends Command
$io->newLine(2);
}
$io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total));
if ($toWarm !== []) {
$io->writeln('Verifying <comment>NIP-05</comment> (HTTPS <comment>/.well-known/nostr.json</comment>, per identifier)…');
$nt = 0;
$nv = 0;
$domain = trim((string) $this->params->get('nip05_domain'));
foreach ($toWarm as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$hex = strtolower($hex);
$npub = $keys->convertPublicKeyToBech32($hex);
$bundle = $this->cacheService->getMetadataBundle($npub);
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []);
$fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex);
if ($fa !== null && $fa->isListed() && $domain !== '') {
$rows = $this->profileIdentityLinks->mergeSiteNip05IntoList(
$rows,
$fa->getLocalPart().'@'.$domain
);
}
foreach ($rows as $r) {
++$nt;
$label = (string) ($r['label'] ?? '');
if ($this->nip05Verification->verifyAndCache($hex, $label)) {
++$nv;
}
}
}
$failed = $nt - $nv;
$io->writeln(sprintf(
' <info>%d</info> identifier(s) checked: <info>%d</info> verified, <comment>%d</comment> not verified.',
$nt,
$nv,
$failed
));
}
} else {
$io->note('Skipping metadata (--no-metadata).');
}

15
src/Controller/AuthorController.php

@ -5,7 +5,9 @@ declare(strict_types=1); @@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
use App\Service\Nip05VerificationService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
@ -26,6 +28,8 @@ class AuthorController extends AbstractController @@ -26,6 +28,8 @@ class AuthorController extends AbstractController
NostrClient $nostrClient,
CacheService $cacheService,
ArticleRepository $articleRepository,
FeaturedAuthorRepository $featuredAuthorRepository,
Nip05VerificationService $nip05Verification,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ProfileIdentityLinksBuilder $profileIdentityLinks,
): Response {
@ -70,13 +74,22 @@ class AuthorController extends AbstractController @@ -70,13 +74,22 @@ class AuthorController extends AbstractController
$jumbleBase = rtrim($jumbleBase, '/');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) {
$nipDomain = trim((string) $this->getParameter('nip05_domain'));
$siteNip = $fa->getLocalPart().($nipDomain !== '' ? '@'.$nipDomain : '');
$profileNip05 = $profileIdentityLinks->mergeSiteNip05IntoList($profileNip05, $siteNip);
}
$profileNip05 = $nip05Verification->enrichRowsWithCache($pubkey, $profileNip05);
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_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]);

3
src/Controller/FeaturedAuthorsController.php

@ -36,14 +36,11 @@ final class FeaturedAuthorsController extends AbstractController @@ -36,14 +36,11 @@ final class FeaturedAuthorsController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$siteNip05 = $fa->getLocalPart().($domain !== '' ? '@'.$domain : '');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$authors[] = [
'author' => $author,
'npub' => $npub,
'site_nip05' => $siteNip05,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []),
'jumble_profile_href' => $jumbleProfileHref,
];

4
src/Service/MagazineRefresher.php

@ -32,7 +32,8 @@ final class MagazineRefresher @@ -32,7 +32,8 @@ final class MagazineRefresher
* 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)
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
* `category_fetched` (step, total_steps, slug)
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{
@ -72,6 +73,7 @@ final class MagazineRefresher @@ -72,6 +73,7 @@ final class MagazineRefresher
'total_steps' => $totalSteps,
'step' => 1,
'slug_count' => \count($slugs),
'slugs' => $slugs,
]);
$step = 1;
foreach ($slugs as $slug) {

214
src/Service/Nip05VerificationService.php

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
/**
* Fetches <domain>/.well-known/nostr.json and checks the listed pubkey (NIP-05).
* Results are stored in the app cache for UI badges and to avoid re-fetching on every request.
*/
final readonly class Nip05VerificationService
{
private const CACHE_PREFIX = 'nip05v1_';
private const FETCH_TIMEOUT_SEC = 8;
public function __construct(
private CacheItemPoolInterface $appCache,
private LoggerInterface $logger,
) {
}
/**
* @param list<array{label: string, href: string, verified?: bool}> $rows
*
* @return list<array{label: string, href: string, verified: bool}>
*/
public function enrichRowsWithCache(string $authorPubkeyHex, array $rows): array
{
if ($rows === []) {
return [];
}
$h = strtolower($authorPubkeyHex);
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return array_map(static function (array $r): array {
return [...$r, 'verified' => false];
}, $rows);
}
$out = [];
foreach ($rows as $r) {
$label = (string) ($r['label'] ?? '');
$n = $this->normalizeNip05($label);
if ($n === null) {
$out[] = [...$r, 'verified' => false];
continue;
}
$k = $this->cacheKey($h, $n);
$verified = false;
try {
$item = $this->appCache->getItem($k);
if ($item->isHit() && is_bool($item->get())) {
$verified = (bool) $item->get();
}
} catch (InvalidArgumentException) {
}
$out[] = [...$r, 'verified' => $verified];
}
return $out;
}
/**
* Fetches the document and records success or failure in cache (24h).
*/
public function verifyAndCache(string $authorPubkeyHex, string $nip05Label): bool
{
$h = strtolower($authorPubkeyHex);
if (64 !== \strlen($h) || !ctype_xdigit($h)) {
return false;
}
$n = $this->normalizeNip05($nip05Label);
if ($n === null) {
return false;
}
$k = $this->cacheKey($h, $n);
$ok = $this->checkRemote($h, $n);
try {
$item = $this->appCache->getItem($k);
$item->set($ok);
$item->expiresAfter(86_400);
$this->appCache->save($item);
} catch (InvalidArgumentException $e) {
$this->logger->warning('nip05.verify_cache_write_failed', [
'message' => $e->getMessage(),
]);
}
return $ok;
}
private function cacheKey(string $hexLower, string $nip05Lower): string
{
return self::CACHE_PREFIX.hash('sha256', $hexLower."\0".$nip05Lower);
}
private function normalizeNip05(string $raw): ?string
{
$s = trim(strtolower($raw));
if ($s === '' || !str_contains($s, '@')) {
return null;
}
$p = explode('@', $s, 2);
if (($p[0] ?? '') === '' || ($p[1] ?? '') === '' || str_contains($p[1], ' ')) {
return null;
}
return $s;
}
private function checkRemote(string $expectedHex, string $nip05Lower): bool
{
$parts = explode('@', $nip05Lower, 2);
$local = (string) ($parts[0] ?? '');
$domain = (string) ($parts[1] ?? '');
if ($local === '' || $domain === '') {
return false;
}
$url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local);
$http_response_header = [];
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json,\r\n",
'timeout' => self::FETCH_TIMEOUT_SEC,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
$this->logger->info('nip05.verify_fetch_failed', [
'nip05' => $nip05Lower,
]);
return false;
}
$statusLine = (isset($http_response_header) && \is_array($http_response_header))
? (string) ($http_response_header[0] ?? '')
: '';
if (!preg_match('#\b200\b#', $statusLine)) {
$this->logger->info('nip05.verify_not_200', [
'nip05' => $nip05Lower,
'status' => $statusLine,
]);
return false;
}
try {
$data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return false;
}
if (!\is_array($data) || !isset($data['names']) || !\is_array($data['names'])) {
return false;
}
$val = $this->lookupNameInNames($data['names'], $local);
if (!\is_string($val) || $val === '') {
return false;
}
$rowHex = $this->toHex64($val);
if ($rowHex === null) {
return false;
}
return hash_equals($expectedHex, $rowHex);
}
/**
* @param array<array-key, mixed> $names
*/
private function lookupNameInNames(array $names, string $localWanted): mixed
{
if (isset($names[$localWanted])) {
return $names[$localWanted];
}
$lw = strtolower($localWanted);
foreach ($names as $k => $v) {
if (\is_string($k) && strtolower($k) === $lw) {
return $v;
}
}
return null;
}
private function toHex64(string $v): ?string
{
$v = trim($v);
if (64 === \strlen($v) && ctype_xdigit($v)) {
return strtolower($v);
}
if (str_starts_with($v, 'npub1')) {
try {
$k = new Key();
$hex = $k->convertToHex($v);
if (64 === \strlen($hex) && ctype_xdigit($hex)) {
return strtolower($hex);
}
} catch (\Throwable) {
}
}
return null;
}
}

37
src/Service/ProfileIdentityLinksBuilder.php

@ -94,6 +94,43 @@ final class ProfileIdentityLinksBuilder @@ -94,6 +94,43 @@ final class ProfileIdentityLinksBuilder
return $out;
}
/**
* Adds a site-assigned NIP-05 (e.g. under the blog domain) into the same list as profile NIP-05,
* with the same link shape as {@see buildNip05}, deduped by label.
*
* @param list<array{label: string, href: string}> $rows
*
* @return list<array{label: string, href: string}>
*/
public function mergeSiteNip05IntoList(array $rows, string $siteNip05): array
{
$siteNip05 = trim(strtolower($siteNip05));
if ($siteNip05 === '' || !str_contains($siteNip05, '@')) {
return $rows;
}
$seen = [];
foreach ($rows as $r) {
$seen[strtolower((string) ($r['label'] ?? ''))] = true;
}
if (isset($seen[$siteNip05])) {
return $rows;
}
$parts = explode('@', $siteNip05, 2);
$local = $parts[0] ?? '';
$domain = $parts[1] ?? '';
if ($local === '' || $domain === '' || str_contains($domain, ' ')) {
return $rows;
}
$href = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local);
$rows[] = [
'label' => $siteNip05,
'href' => $href,
];
usort($rows, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label']));
return $rows;
}
/**
* @return list<string>
*/

119
src/Service/ProfilePaymentLinksBuilder.php

@ -26,7 +26,15 @@ final class ProfilePaymentLinksBuilder @@ -26,7 +26,15 @@ final class ProfilePaymentLinksBuilder
* @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}>
* @return list<array{
* type: string,
* type_label: string,
* label: string,
* href: string,
* sort: int,
* group_key: string,
* display_type_label: string
* }>
*/
public function buildPaymentRows(object $content, array $kind0Tags, array $extraPaytoUris = []): array
{
@ -44,7 +52,7 @@ final class ProfilePaymentLinksBuilder @@ -44,7 +52,7 @@ final class ProfilePaymentLinksBuilder
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LIGHTNING_ADDRESS,
'type_label' => 'Lightning address',
'type_label' => 'Lightning',
'label' => $addr,
'href' => 'lightning:'.$addr,
'sort' => 0,
@ -59,7 +67,7 @@ final class ProfilePaymentLinksBuilder @@ -59,7 +67,7 @@ final class ProfilePaymentLinksBuilder
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LNURL_PAY,
'type_label' => 'LNURL Pay',
'type_label' => 'Lightning',
'label' => $this->shortenLnurl($ln),
'href' => 'lightning:'.$ln,
'sort' => 1,
@ -67,6 +75,8 @@ final class ProfilePaymentLinksBuilder @@ -67,6 +75,8 @@ final class ProfilePaymentLinksBuilder
}
}
$lud16ForDedup = $resolved['lightning_address'];
$allPayto = array_merge(
$this->paytoUrisFromJsonObject($content),
$this->paytoUrisFromNipA3StyleTags($kind0Tags),
@ -80,6 +90,9 @@ final class ProfilePaymentLinksBuilder @@ -80,6 +90,9 @@ final class ProfilePaymentLinksBuilder
if (!self::isPaytoOrLegacyPaytoScheme($uri)) {
continue;
}
if ($lud16ForDedup !== null && self::paytoLightningUriMatchesLightningAddress($uri, $lud16ForDedup)) {
continue;
}
$canon = self::normalizePaytoUriForDedup($uri);
if (isset($seen[$canon])) {
continue;
@ -87,7 +100,7 @@ final class ProfilePaymentLinksBuilder @@ -87,7 +100,7 @@ final class ProfilePaymentLinksBuilder
$seen[$canon] = true;
$rows[] = [
'type' => self::TYPE_PAYTO,
'type_label' => 'Payto',
'type_label' => 'Pay to',
'label' => $this->labelForPaytoUri($uri),
'href' => $uri,
'sort' => 2,
@ -112,7 +125,77 @@ final class ProfilePaymentLinksBuilder @@ -112,7 +125,77 @@ final class ProfilePaymentLinksBuilder
}
);
return $rows;
return $this->collapseGroupLabels($rows);
}
/**
* Consecutive rows with the same {@see group_key} only show the first column label on the first row
* (e.g. multiple Lightning lines, then Monero).
*
* @param list<array<string, mixed>> $rows
*
* @return list<array<string, mixed>>
*/
private function collapseGroupLabels(array $rows): array
{
$prevKey = null;
$out = [];
foreach ($rows as $r) {
$gk = $this->rowGroupKey($r);
$col = $this->rowGroupColumnLabel($r, $gk);
$r['group_key'] = $gk;
$r['display_type_label'] = $gk === $prevKey ? '' : $col;
$prevKey = $gk;
$out[] = $r;
}
return $out;
}
/**
* @param array<string, mixed> $r
*/
private function rowGroupKey(array $r): string
{
$t = (string) ($r['type'] ?? '');
if ($t === self::TYPE_LIGHTNING_ADDRESS || $t === self::TYPE_LNURL_PAY) {
return 'lightning';
}
if ($t === self::TYPE_PAYTO) {
$h = strtolower((string) ($r['href'] ?? ''));
if (1 === preg_match('#^payto://([a-z0-9-]+)/#i', $h, $m)) {
$sc = strtolower($m[1]);
if ($sc === 'lightning') {
return 'lightning';
}
return 'payto:'.$sc;
}
return 'payto:other';
}
return 'other';
}
/**
* @param array<string, mixed> $r
*/
private function rowGroupColumnLabel(array $r, string $groupKey): string
{
if ($groupKey === 'lightning') {
return 'Lightning';
}
if (str_starts_with($groupKey, 'payto:')) {
$s = substr($groupKey, 6);
if ($s === 'other') {
return 'Pay to';
}
return $this->stylizePaytoTypeName($s);
}
return (string) ($r['type_label'] ?? 'Pay to');
}
/**
@ -365,6 +448,32 @@ final class ProfilePaymentLinksBuilder @@ -365,6 +448,32 @@ final class ProfilePaymentLinksBuilder
return substr($lnurl, 0, 10).'…'.substr($lnurl, -8);
}
/**
* Skips NIP-A3 / JSON {@see payto://lightning/…} rows that repeat the LUD16 lightning address
* (e.g. same as {@see TYPE_LIGHTNING_ADDRESS} with {@code lightning:user@host}).
*/
private static function paytoLightningUriMatchesLightningAddress(string $uri, string $lud16Email): bool
{
if (!str_starts_with(strtolower($uri), 'payto://lightning/')) {
return false;
}
$lud = strtolower(trim($lud16Email));
if ($lud === '' || !str_contains($lud, '@')) {
return false;
}
if (1 !== preg_match('#^payto://lightning/(.+)$#i', $uri, $m)) {
return false;
}
$tail = (string) $m[1];
$first = (string) (str_contains($tail, '/') ? strstr($tail, '/', true) : $tail);
if ($first === '') {
$first = $tail;
}
$first = strtolower(rawurldecode($first));
return $first === $lud;
}
/**
* JSON: strings (full `payto:` / `payto://` URI), or objects with `type`+`authority` (NIP-A3-style).
*

1
templates/pages/author.html.twig

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
{% include 'partial/author_profile_header.html.twig' with {
author: author,
npub: npub,
show_nip05: true,
profile_websites: profile_websites,
profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links,

4
templates/pages/featured_authors.html.twig

@ -22,11 +22,11 @@ @@ -22,11 +22,11 @@
author: row.author,
npub: row.npub,
header_tag: 'h2',
show_nip05: false,
profile_nip05: [],
profile_websites: row.profile_websites,
profile_nip05: row.profile_nip05,
profile_payment_links: row.profile_payment_links,
jumble_profile_href: row.jumble_profile_href,
site_nip05: row.site_nip05,
} only %}
</div>
<p class="featured-authors__more">

35
templates/partial/author_profile_header.html.twig

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; optional site_nip05 #}
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; show_nip05: true on full /p/ profile only #}
{% set author_pic = null %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
@ -20,37 +20,34 @@ @@ -20,37 +20,34 @@
{% 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">
<li class="author-profile__identity-row author-profile__meta-line">
<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>
<a class="author-profile__identity-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if site_nip05|default('')|trim != '' %}
<ul class="author-profile__identity" aria-label="Magazine NIP-05">
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Magazine</span>
<span class="author-profile__site-nip05 text-subtle">{{ site_nip05|e }}</span>
</li>
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
{% if show_nip05|default(false) and 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">
<li class="author-profile__identity-row author-profile__meta-line">
<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>
<span class="author-profile__meta-value author-profile__nip05-value">
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer" title="Open /.well-known/nostr.json for this name">{{ row.label|e }}</a>
{% if row.verified|default(false) %}
<span class="author-profile__nip05-verified" title="This identifier matches the pubkey in /.well-known/nostr.json" aria-label="Verified NIP-05">✓</span>
{% endif %}
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
<ul class="author-profile__payments" aria-label="Payment (Lightning and payto)">
{% 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 class="author-profile__payment author-profile__meta-line">
<span class="author-profile__payment-type"{% if row.display_type_label|default('')|trim == '' %} aria-hidden="true"{% endif %}>{{ row.display_type_label|default('')|e }}</span>
<a class="author-profile__payment-link author-profile__meta-value" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
@ -65,6 +62,6 @@ @@ -65,6 +62,6 @@
{% 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>
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</p>
{% endif %}

Loading…
Cancel
Save