You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

162 lines
4.8 KiB

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\FeaturedAuthor;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Reconciles {@see FeaturedAuthor} rows with pubkeys found in magazine category `a` tags.
* The listed set is derived from current category indices during prewarm.
*/
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,
private readonly NostrKeyHelper $nostrKeyHelper,
) {
}
/**
* @return array{added: int, relisted: int, unlisted: int, listed_total: int}
*/
public function reconcileListedAuthorsFromMagazineCategories(): array
{
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
$target = [];
foreach ($pubkeys as $hex) {
$h = strtolower(trim($hex));
if (64 === \strlen($h) && ctype_xdigit($h)) {
$target[$h] = true;
}
}
$existingByPubkey = [];
foreach ($this->featuredAuthorRepository->findAll() as $row) {
$existingByPubkey[strtolower($row->getPubkeyHex())] = $row;
}
$added = 0;
$relisted = 0;
$unlisted = 0;
$changed = false;
foreach (array_keys($target) as $hex) {
$row = $existingByPubkey[$hex] ?? null;
if ($row === null) {
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$entity->setIsListed(true);
$this->entityManager->persist($entity);
$existingByPubkey[$hex] = $entity;
$added++;
$changed = true;
continue;
}
if (!$row->isListed()) {
$row->setIsListed(true);
$relisted++;
$changed = true;
}
}
foreach ($existingByPubkey as $hex => $row) {
if (isset($target[$hex])) {
continue;
}
if ($row->isListed()) {
$row->setIsListed(false);
$unlisted++;
$changed = true;
}
}
if ($changed) {
$this->entityManager->flush();
$this->logger->info('featured_author.sync', [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
]);
}
return [
'added' => $added,
'relisted' => $relisted,
'unlisted' => $unlisted,
'listed_total' => \count($target),
];
}
private function deriveBaseLocalPart(string $pubkeyHex): string
{
try {
$npub = $this->nostrKeyHelper->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));
}
}