16 changed files with 720 additions and 73 deletions
@ -0,0 +1,26 @@ |
|||||||
|
<?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'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
<?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']; |
||||||
|
$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, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->render('pages/featured_authors.html.twig', [ |
||||||
|
'authors' => $authors, |
||||||
|
'nip05_domain' => $domain, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
<?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] |
||||||
|
#[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; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
<?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(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<?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)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
{% 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', |
||||||
|
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"> |
||||||
|
<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 %} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; optional site_nip05 #} |
||||||
|
{% 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"> |
||||||
|
<span class="author-profile__identity-type">Website</span> |
||||||
|
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endif %} |
||||||
|
{% if 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 %} |
||||||
|
<ul class="author-profile__identity" aria-label="NIP-05"> |
||||||
|
{% for row in profile_nip05 %} |
||||||
|
<li class="author-profile__identity-row"> |
||||||
|
<span class="author-profile__identity-type">NIP-05</span> |
||||||
|
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endif %} |
||||||
|
{% if profile_payment_links is not empty %} |
||||||
|
<ul class="author-profile__payments" aria-label="Payment options"> |
||||||
|
{% for row in profile_payment_links %} |
||||||
|
<li class="author-profile__payment"> |
||||||
|
<span class="author-profile__payment-type">{{ row.type_label }}</span> |
||||||
|
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="author-profile__about"> |
||||||
|
{% if author.about is defined %} |
||||||
|
{{ author.about|markdown_to_html|mentionify|linkify }} |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if jumble_profile_href is not null and jumble_profile_href != '' %} |
||||||
|
<p class="author-profile__jumble"> |
||||||
|
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a> |
||||||
|
</p> |
||||||
|
{% endif %} |
||||||
Loading…
Reference in new issue