16 changed files with 720 additions and 73 deletions
@ -0,0 +1,26 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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