Compare commits
No commits in common. '2b31b5d1de602c4bcdc2682384c1453978fe62ab' and '0be99e87edb36948d1088bdcbae8235e759c9a80' have entirely different histories.
2b31b5d1de
...
0be99e87ed
49 changed files with 651 additions and 2315 deletions
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
import { Controller } from "@hotwired/stimulus"; |
||||
|
||||
/** |
||||
* After first paint, refreshes Nostr magazine indices (server-side, ≤5s) and swaps header/body HTML. |
||||
*/ |
||||
export default class extends Controller { |
||||
static targets = ["headerNav", "pageBody"]; |
||||
static values = { |
||||
page: String, |
||||
slug: String, |
||||
url: String, |
||||
}; |
||||
|
||||
connect() { |
||||
this.sync(); |
||||
} |
||||
|
||||
async sync() { |
||||
const base = this.urlValue || "/ux/magazine-sync"; |
||||
const params = new URLSearchParams(); |
||||
params.set("page", this.pageValue || "article"); |
||||
const slug = this.slugValue || ""; |
||||
if (slug !== "") { |
||||
params.set("slug", slug); |
||||
} |
||||
const url = `${base}?${params.toString()}`; |
||||
try { |
||||
const res = await fetch(url, { |
||||
headers: { Accept: "application/json" }, |
||||
credentials: "same-origin", |
||||
}); |
||||
if (!res.ok) { |
||||
return; |
||||
} |
||||
const data = await res.json(); |
||||
if (!data.ok) { |
||||
return; |
||||
} |
||||
if (this.hasHeaderNavTarget && data.header) { |
||||
this.headerNavTarget.outerHTML = data.header; |
||||
} |
||||
if (this.hasPageBodyTarget && data.body) { |
||||
this.pageBodyTarget.outerHTML = data.body; |
||||
} |
||||
} catch { |
||||
/* ignore network errors */ |
||||
} |
||||
} |
||||
} |
||||
@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
final class Version20260423120000 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return 'Featured authors for site NIP-05 (category authors)'; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
$this->addSql('CREATE TABLE featured_author (id INT AUTO_INCREMENT NOT NULL, pubkey_hex VARCHAR(64) NOT NULL, local_part VARCHAR(100) NOT NULL, is_listed TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8EED8C6CE479AD9 (pubkey_hex), UNIQUE INDEX UNIQ_8EED8C6CEEEB401 (local_part), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
$this->addSql('DROP TABLE featured_author'); |
||||
} |
||||
} |
||||
@ -1,54 +0,0 @@
@@ -1,54 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Repository\FeaturedAuthorRepository; |
||||
use App\Service\CacheService; |
||||
use App\Service\ProfileIdentityLinksBuilder; |
||||
use App\Service\ProfilePaymentLinksBuilder; |
||||
use swentel\nostr\Key\Key; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
/** |
||||
* Renders the site-managed NIP-05 list of magazine category authors. |
||||
*/ |
||||
final class FeaturedAuthorsController extends AbstractController |
||||
{ |
||||
#[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])] |
||||
public function index( |
||||
FeaturedAuthorRepository $featuredAuthorRepository, |
||||
CacheService $cacheService, |
||||
ProfileIdentityLinksBuilder $profileIdentityLinks, |
||||
ProfilePaymentLinksBuilder $profilePaymentLinks, |
||||
ParameterBagInterface $params, |
||||
): Response { |
||||
$domain = trim((string) $params->get('nip05_domain')); |
||||
$jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/'); |
||||
$keys = new Key(); |
||||
$authors = []; |
||||
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { |
||||
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex()); |
||||
$bundle = $cacheService->getMetadataBundle($npub); |
||||
$author = $bundle['content']; |
||||
$kind0Tags = $bundle['kind0_tags']; |
||||
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null; |
||||
$authors[] = [ |
||||
'author' => $author, |
||||
'npub' => $npub, |
||||
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), |
||||
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []), |
||||
'jumble_profile_href' => $jumbleProfileHref, |
||||
]; |
||||
} |
||||
|
||||
return $this->render('pages/featured_authors.html.twig', [ |
||||
'authors' => $authors, |
||||
'nip05_domain' => $domain, |
||||
]); |
||||
} |
||||
} |
||||
@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Service\MagazineContentService; |
||||
use App\Service\MagazineRefresher; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\HttpKernel\Attribute\AsController; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Twig\Environment; |
||||
|
||||
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */ |
||||
#[AsController] |
||||
final class MagazineSyncController |
||||
{ |
||||
public function __construct( |
||||
private readonly Environment $twig, |
||||
private readonly MagazineRefresher $refresher, |
||||
private readonly MagazineContentService $magazineContent, |
||||
private readonly ParameterBagInterface $params, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])] |
||||
public function __invoke(Request $request): JsonResponse |
||||
{ |
||||
try { |
||||
$page = (string) $request->query->get('page', 'article'); |
||||
if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) { |
||||
$page = 'article'; |
||||
} |
||||
$slug = (string) $request->query->get('slug', ''); |
||||
|
||||
$prefer = $slug !== '' ? [$slug] : []; |
||||
|
||||
try { |
||||
$this->refresher->refreshFromRelays(20, $prefer); |
||||
} catch (\Throwable $e) { |
||||
$this->logger->warning('MagazineSyncController: refresh failed', [ |
||||
'message' => $e->getMessage(), |
||||
'exception' => $e, |
||||
]); |
||||
|
||||
return new JsonResponse( |
||||
['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()], |
||||
Response::HTTP_OK |
||||
); |
||||
} |
||||
|
||||
$community = (bool) $this->params->get('community_articles'); |
||||
$tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(); |
||||
$globals = [ |
||||
'magazine_community_articles' => $community, |
||||
]; |
||||
|
||||
$header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [ |
||||
'cats' => $tags, |
||||
])); |
||||
|
||||
$body = null; |
||||
if ($page === 'home') { |
||||
$body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [ |
||||
'indices' => $tags, |
||||
])); |
||||
} elseif ($page === 'category' && $slug !== '') { |
||||
$data = $this->magazineContent->getCategoryPageData($slug); |
||||
$body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [ |
||||
'list' => $data['list'], |
||||
'category' => $data['category'], |
||||
])); |
||||
} elseif ($page === 'articles') { |
||||
$body = null; |
||||
} |
||||
|
||||
return new JsonResponse([ |
||||
'ok' => true, |
||||
'header' => $header, |
||||
'body' => $body, |
||||
]); |
||||
} catch (\Throwable $e) { |
||||
$this->logger->error('MagazineSyncController: unexpected failure', [ |
||||
'message' => $e->getMessage(), |
||||
'exception' => $e, |
||||
]); |
||||
|
||||
return new JsonResponse( |
||||
[ |
||||
'ok' => false, |
||||
'error' => 'server_error', |
||||
'message' => 'Magazine UI sync could not be rendered.', |
||||
], |
||||
Response::HTTP_OK |
||||
); |
||||
} |
||||
} |
||||
} |
||||
@ -1,89 +0,0 @@
@@ -1,89 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Entity; |
||||
|
||||
use App\Repository\FeaturedAuthorRepository; |
||||
use Doctrine\DBAL\Types\Types; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
|
||||
/** |
||||
* Site-assigned NIP-05 for authors who appear in a magazine category index. |
||||
* Rows are only removed or deactivated manually (is_listed = false); sync only adds new pubkeys. |
||||
*/ |
||||
#[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)] |
||||
#[ORM\Table(name: 'featured_author')] |
||||
class FeaturedAuthor |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')] |
||||
#[ORM\Column] |
||||
private ?int $id = null; |
||||
|
||||
#[ORM\Column(length: 64, unique: true)] |
||||
private string $pubkeyHex = ''; |
||||
|
||||
/** |
||||
* NIP-05 local-part (a–z, 0–9, -, _, .) unique across all rows. |
||||
*/ |
||||
#[ORM\Column(length: 100, unique: true)] |
||||
private string $localPart = ''; |
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] |
||||
private bool $isListed = true; |
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] |
||||
private \DateTimeImmutable $createdAt; |
||||
|
||||
public function __construct() |
||||
{ |
||||
$this->createdAt = new \DateTimeImmutable(); |
||||
} |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getPubkeyHex(): string |
||||
{ |
||||
return $this->pubkeyHex; |
||||
} |
||||
|
||||
public function setPubkeyHex(string $pubkeyHex): static |
||||
{ |
||||
$this->pubkeyHex = $pubkeyHex; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getLocalPart(): string |
||||
{ |
||||
return $this->localPart; |
||||
} |
||||
|
||||
public function setLocalPart(string $localPart): static |
||||
{ |
||||
$this->localPart = $localPart; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function isListed(): bool |
||||
{ |
||||
return $this->isListed; |
||||
} |
||||
|
||||
public function setIsListed(bool $isListed): static |
||||
{ |
||||
$this->isListed = $isListed; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable |
||||
{ |
||||
return $this->createdAt; |
||||
} |
||||
} |
||||
@ -1,54 +0,0 @@
@@ -1,54 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Repository; |
||||
|
||||
use App\Entity\FeaturedAuthor; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
/** |
||||
* @extends ServiceEntityRepository<FeaturedAuthor> |
||||
*/ |
||||
class FeaturedAuthorRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry) |
||||
{ |
||||
parent::__construct($registry, FeaturedAuthor::class); |
||||
} |
||||
|
||||
public function findOneByPubkeyHex(string $pubkeyHex): ?FeaturedAuthor |
||||
{ |
||||
$h = strtolower($pubkeyHex); |
||||
|
||||
return $this->findOneBy(['pubkeyHex' => $h]); |
||||
} |
||||
|
||||
public function isLocalPartTaken(string $localPart, ?int $exceptId = null): bool |
||||
{ |
||||
$qb = $this->createQueryBuilder('f') |
||||
->select('COUNT(f.id)') |
||||
->where('f.localPart = :lp') |
||||
->setParameter('lp', $localPart); |
||||
if ($exceptId !== null) { |
||||
$qb->andWhere('f.id != :eid')->setParameter('eid', $exceptId); |
||||
} |
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0; |
||||
} |
||||
|
||||
/** |
||||
* @return list<FeaturedAuthor> |
||||
*/ |
||||
public function findAllListedOrderByLocalPart(): array |
||||
{ |
||||
return $this->createQueryBuilder('f') |
||||
->where('f.isListed = :t') |
||||
->setParameter('t', true) |
||||
->orderBy('f.localPart', 'ASC') |
||||
->getQuery() |
||||
->getResult(); |
||||
} |
||||
|
||||
} |
||||
@ -1,120 +0,0 @@
@@ -1,120 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\FeaturedAuthor; |
||||
use App\Repository\FeaturedAuthorRepository; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Key\Key; |
||||
|
||||
/** |
||||
* Adds {@see FeaturedAuthor} rows for pubkeys found in magazine category indices; assigns |
||||
* unique NIP-05 local-parts from kind-0 name when possible. Does not remove or re-list rows. |
||||
*/ |
||||
final class FeaturedAuthorSync |
||||
{ |
||||
public function __construct( |
||||
private readonly MagazineContentService $magazineContent, |
||||
private readonly FeaturedAuthorRepository $featuredAuthorRepository, |
||||
private readonly CacheService $cacheService, |
||||
private readonly EntityManagerInterface $entityManager, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @return int Number of newly persisted authors |
||||
*/ |
||||
public function syncNewAuthorsFromMagazineCategories(): int |
||||
{ |
||||
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(); |
||||
if ($pubkeys === []) { |
||||
return 0; |
||||
} |
||||
|
||||
$keys = new Key(); |
||||
$n = 0; |
||||
foreach ($pubkeys as $hex) { |
||||
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) { |
||||
continue; |
||||
} |
||||
$entity = new FeaturedAuthor(); |
||||
$entity->setPubkeyHex($hex); |
||||
$base = $this->deriveBaseLocalPart($keys, $hex); |
||||
$entity->setLocalPart($this->allocateUniqueLocalPart($base)); |
||||
$this->entityManager->persist($entity); |
||||
++$n; |
||||
} |
||||
if ($n > 0) { |
||||
$this->entityManager->flush(); |
||||
$this->logger->info('featured_author.sync', ['new_count' => $n]); |
||||
} |
||||
|
||||
return $n; |
||||
} |
||||
|
||||
private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string |
||||
{ |
||||
try { |
||||
$npub = $keys->convertPublicKeyToBech32($pubkeyHex); |
||||
} catch (\Throwable) { |
||||
$npub = null; |
||||
} |
||||
if (!\is_string($npub) || $npub === '') { |
||||
return 'author'.substr($pubkeyHex, 0, 8); |
||||
} |
||||
$name = ''; |
||||
try { |
||||
$c = $this->cacheService->getMetadata($npub); |
||||
$name = (string) ($c->display_name ?? $c->name ?? ''); |
||||
} catch (\Throwable) { |
||||
} |
||||
$base = $this->nip05LocalPartFromLabel($name); |
||||
if ($base === '') { |
||||
$base = 'author'.substr($pubkeyHex, 0, 8); |
||||
} |
||||
|
||||
return $base; |
||||
} |
||||
|
||||
/** |
||||
* NIP-05: local-part uses only a–z, 0–9, -, _, . |
||||
*/ |
||||
private function nip05LocalPartFromLabel(string $raw): string |
||||
{ |
||||
$s = strtolower(trim($raw)); |
||||
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s); |
||||
if (\is_string($t) && $t !== '') { |
||||
$s = strtolower($t); |
||||
} |
||||
$s = preg_replace('/[^a-z0-9._-]+/', '', $s) ?? ''; |
||||
$s = trim((string) $s, '._-'); |
||||
if (\strlen($s) > 40) { |
||||
$s = substr($s, 0, 40); |
||||
} |
||||
$s = trim($s, '._-'); |
||||
|
||||
return $s; |
||||
} |
||||
|
||||
private function allocateUniqueLocalPart(string $base): string |
||||
{ |
||||
if ($base === '') { |
||||
$base = 'author'; |
||||
} |
||||
if (!$this->featuredAuthorRepository->isLocalPartTaken($base)) { |
||||
return $base; |
||||
} |
||||
for ($i = 1; $i < 10_000; ++$i) { |
||||
$c = $base.$i; |
||||
if (!$this->featuredAuthorRepository->isLocalPartTaken($c)) { |
||||
return $c; |
||||
} |
||||
} |
||||
|
||||
return $base.bin2hex(random_bytes(3)); |
||||
} |
||||
} |
||||
@ -1,214 +0,0 @@
@@ -1,214 +0,0 @@
|
||||
<?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; |
||||
} |
||||
} |
||||
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Organisms; |
||||
|
||||
use App\Service\ArticleCommentThreadLoader; |
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class Comments |
||||
{ |
||||
public array $list = []; |
||||
|
||||
public array $commentLinks = []; |
||||
|
||||
public array $processedContent = []; |
||||
|
||||
public function __construct(private readonly ArticleCommentThreadLoader $commentThreadLoader) |
||||
{ |
||||
} |
||||
|
||||
public function mount($current): void |
||||
{ |
||||
$data = $this->commentThreadLoader->load((string) $current); |
||||
$this->list = $data['list']; |
||||
$this->commentLinks = $data['commentLinks']; |
||||
$this->processedContent = $data['processedContent']; |
||||
} |
||||
} |
||||
@ -1,41 +1,13 @@
@@ -1,41 +1,13 @@
|
||||
<div class="site-footer"> |
||||
<div class="site-footer__syndication"> |
||||
<h2 class="site-footer__syndication-title">Sitemap and feeds</h2> |
||||
<p class="site-footer__syndication-hint">For search engines and feed readers. Atom is supported by most clients.</p> |
||||
<nav class="site-footer__nav" aria-label="Sitemap, feeds, and index"> |
||||
<ul class="site-footer__syndication-list"> |
||||
<li><a class="site-footer__link" href="{{ path('featured_authors') }}">Featured authors</a></li> |
||||
<li><a class="site-footer__link" href="{{ path('sitemap') }}">Sitemap (XML)</a></li> |
||||
<li><a class="site-footer__link" href="{{ path('robots_txt') }}">Robots</a></li> |
||||
<li class="site-footer__syndication-list__feeds"> |
||||
<span class="site-footer__feeds-icon" title="RSS/Atom" aria-hidden="true"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="16" height="16" focusable="false" fill="currentColor"> |
||||
<circle cx="1.5" cy="6.5" r="1"/> |
||||
<path d="M0 3.5A4.5 4.5 0 0 1 4.5 8V6A2.5 2.5 0 0 0 2 3.5H0z"/> |
||||
<path d="M0 0A8 8 0 0 1 8 8H6.5A6.5 6.5 0 0 0 0 1.5V0z"/> |
||||
</svg> |
||||
</span> |
||||
<a class="site-footer__link" href="{{ path('feed_magazine') }}">All articles</a> |
||||
{% for c in categoriesForFeed %} |
||||
<a class="site-footer__link" href="{{ path('feed_category', {slug: c.slug}) }}">{{ c.title }}</a> |
||||
{% endfor %} |
||||
</li> |
||||
</ul> |
||||
</nav> |
||||
</div> |
||||
<div class="site-footer__main"> |
||||
<div class="footer-links"> |
||||
{% for link in footer_links %} |
||||
<div class="footer-link"> |
||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" title="{{ link.description|default(link.title) }}">{{ link.title }}</a> |
||||
{% if link.description %} |
||||
— <small>{{ link.description }}</small> |
||||
{% endif %} |
||||
</div> |
||||
{% endfor %} |
||||
<div class="footer-links"> |
||||
{% for link in footer_links %} |
||||
<div class="footer-link"> |
||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" title="{{ link.description|default(link.title) }}">{{ link.title }}</a> |
||||
{% if link.description %} |
||||
— <small>{{ link.description }}</small> |
||||
{% endif %} |
||||
</div> |
||||
<p class="site-footer__legal"> |
||||
{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span> |
||||
</p> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
<p>{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span></p> |
||||
|
||||
|
||||
@ -1,43 +0,0 @@
@@ -1,43 +0,0 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<div class="featured-authors"> |
||||
<header class="featured-authors__intro"> |
||||
<h1>Featured authors</h1> |
||||
<p class="text-subtle"> |
||||
Authors whose long-form has been placed in a magazine category receive a |
||||
<abbr title="NIP-05">NIP-05</abbr> identifier |
||||
{% if nip05_domain|default('')|trim != '' %} |
||||
under <strong>{{ nip05_domain|e }}</strong> |
||||
{% endif %} |
||||
for easier discovery. Verification uses <code>/.well-known/nostr.json</code>. |
||||
</p> |
||||
</header> |
||||
|
||||
{% for row in authors %} |
||||
{% set _fa_label = row.author.name|default('')|trim != '' ? row.author.name : (row.npub|shortenNpub) %} |
||||
<article class="featured-authors__card" aria-label="{{ _fa_label|e('html_attr') }}"> |
||||
<div class="author-profile author-profile--featured"> |
||||
{% include 'partial/author_profile_header.html.twig' with { |
||||
author: row.author, |
||||
npub: row.npub, |
||||
header_tag: 'h2', |
||||
show_nip05: false, |
||||
profile_nip05: [], |
||||
profile_websites: row.profile_websites, |
||||
profile_payment_links: row.profile_payment_links, |
||||
jumble_profile_href: row.jumble_profile_href, |
||||
} only %} |
||||
</div> |
||||
<p class="featured-authors__more"> |
||||
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a> |
||||
</p> |
||||
</article> |
||||
{% else %} |
||||
<p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p> |
||||
{% endfor %} |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block aside %} |
||||
{% endblock %} |
||||
@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
|
||||
{# 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 %} |
||||
{% elseif author.image is defined and author.image %} |
||||
{% set author_pic = author.image %} |
||||
{% endif %} |
||||
{% set author_label = author.display_name|default(author.name|default(npub|shortenNpub)) %} |
||||
|
||||
{% if author_pic %} |
||||
<div class="author-profile__avatar"> |
||||
<img src="{{ author_pic }}" alt="{{ author_label }}" loading="lazy" decoding="async" onerror="this.parentElement.remove()" /> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% set header_tag = header_tag|default('h1') %} |
||||
<{{ header_tag }} class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></{{ header_tag }}> |
||||
|
||||
<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 author-profile__meta-line"> |
||||
<span class="author-profile__identity-type">Website</span> |
||||
<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 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 author-profile__meta-line"> |
||||
<span class="author-profile__identity-type">NIP-05</span> |
||||
<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 (Lightning and payto)"> |
||||
{% for row in profile_payment_links %} |
||||
<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> |
||||
{% 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') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a> |
||||
</p> |
||||
{% endif %} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
<div class="category-body" data-magazine-sync-target="pageBody"> |
||||
<twig:Organisms:CardList :list="list" class="article-list" /> |
||||
</div> |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
<ul data-magazine-sync-target="headerNav"> |
||||
{% for category in cats %} |
||||
<li><twig:Molecules:CategoryLink :category="category" /></li> |
||||
{% endfor %} |
||||
{% if magazine_community_articles %} |
||||
<li> |
||||
<a href="{{ path('articles') }}">Latest Articles</a> |
||||
</li> |
||||
{% endif %} |
||||
</ul> |
||||
Loading…
Reference in new issue