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 @@ |
|||||||
|
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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
<div class="site-footer"> |
<div class="footer-links"> |
||||||
<div class="site-footer__syndication"> |
{% for link in footer_links %} |
||||||
<h2 class="site-footer__syndication-title">Sitemap and feeds</h2> |
<div class="footer-link"> |
||||||
<p class="site-footer__syndication-hint">For search engines and feed readers. Atom is supported by most clients.</p> |
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" title="{{ link.description|default(link.title) }}">{{ link.title }}</a> |
||||||
<nav class="site-footer__nav" aria-label="Sitemap, feeds, and index"> |
{% if link.description %} |
||||||
<ul class="site-footer__syndication-list"> |
— <small>{{ link.description }}</small> |
||||||
<li><a class="site-footer__link" href="{{ path('featured_authors') }}">Featured authors</a></li> |
{% endif %} |
||||||
<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> |
</div> |
||||||
<p class="site-footer__legal"> |
{% endfor %} |
||||||
{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span> |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
</div> |
</div> |
||||||
|
|
||||||
|
<p>{{ "now"|date("Y") }} {{ website_name }} <span class="publisher">by <twig:Molecules:UserFromNpub :ident="publisher_npub" /></span></p> |
||||||
|
|
||||||
|
|||||||
@ -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 @@ |
|||||||
{# 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 @@ |
|||||||
|
<div class="category-body" data-magazine-sync-target="pageBody"> |
||||||
|
<twig:Organisms:CardList :list="list" class="article-list" /> |
||||||
|
</div> |
||||||
@ -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