From d5f0a70086f6912a67a1bc648cc2b248ef615805 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 23 Apr 2026 13:34:09 +0200 Subject: [PATCH] bug-fixes --- assets/styles/layout.css | 104 ++++++++++++++- config/unfold.yaml | 2 + migrations/Version20260423120000.php | 26 ++++ src/Command/PrewarmCommand.php | 11 ++ src/Controller/ArticleController.php | 8 +- src/Controller/FeaturedAuthorsController.php | 57 +++++++++ src/Controller/SeoController.php | 64 ++++++++++ src/Entity/FeaturedAuthor.php | 89 +++++++++++++ src/Repository/FeaturedAuthorRepository.php | 54 ++++++++ src/Service/FeaturedAuthorSync.php | 120 ++++++++++++++++++ src/Service/MagazineContentService.php | 37 ++++++ src/Service/MagazineRefresher.php | 9 ++ templates/components/Footer.html.twig | 30 +++-- templates/pages/author.html.twig | 69 ++-------- templates/pages/featured_authors.html.twig | 43 +++++++ .../partial/author_profile_header.html.twig | 70 ++++++++++ 16 files changed, 720 insertions(+), 73 deletions(-) create mode 100644 migrations/Version20260423120000.php create mode 100644 src/Controller/FeaturedAuthorsController.php create mode 100644 src/Entity/FeaturedAuthor.php create mode 100644 src/Repository/FeaturedAuthorRepository.php create mode 100644 src/Service/FeaturedAuthorSync.php create mode 100644 templates/pages/featured_authors.html.twig create mode 100644 templates/partial/author_profile_header.html.twig diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 9e12989..23c1157 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -255,10 +255,78 @@ footer { max-width: 40rem; } -.site-footer__syndication-actions { +.site-footer__nav { + max-width: 44rem; +} + +.site-footer__syndication-list { + display: flex; + flex-wrap: wrap; + align-items: baseline; + row-gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; + font-size: 0.95rem; + line-height: 1.5; +} + +.site-footer__syndication-list > li { display: flex; flex-wrap: wrap; - gap: 0.5rem 0.6rem; + align-items: center; + gap: 0.4rem 0.45rem; +} + +.site-footer__syndication-list > li + li::before { + content: "·"; + color: var(--color-text-mid, #666); + font-weight: 300; + align-self: center; + padding: 0 0.1rem 0 0; +} + +.site-footer__link { + color: var(--color-footer-link); + text-decoration: underline; + text-underline-offset: 2px; + font-weight: 400; +} + +.site-footer__link:hover { + color: var(--color-text); +} + +.site-footer__link:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +/* RSS + category feed links in one cell */ +.site-footer__syndication-list__feeds { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.45rem; + max-width: 100%; +} + +/* Dots between feed links (skip first = "All articles"). */ +.site-footer__syndication-list__feeds a:not(:first-of-type)::before { + content: "·"; + color: var(--color-text-mid, #666); + font-weight: 300; + margin-right: 0.45rem; + text-decoration: none; + display: inline; +} + +.site-footer__feeds-icon { + display: flex; + flex-shrink: 0; + line-height: 0; + color: var(--color-text-mid, #666); + opacity: 0.72; } .site-footer__main { @@ -297,6 +365,38 @@ footer .footer-links { margin: 0 0 0.5rem; } +.featured-authors { + max-width: 48rem; + margin: 0 auto; + padding: 0 0.5rem 2rem; +} + +.featured-authors__intro { + margin-bottom: 2rem; +} + +.featured-authors__intro h1 { + margin-top: 0; +} + +.featured-authors__card { + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.featured-authors__card:last-of-type { + border-bottom: none; +} + +.author-profile--featured .author-profile__title { + font-size: 1.5rem; +} + +.featured-authors__more { + margin: 0.75rem 0 0; +} + .footer-links a { color: var(--color-footer-link); text-decoration: underline; diff --git a/config/unfold.yaml b/config/unfold.yaml index 1bf6cce..739ce3a 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -31,6 +31,8 @@ parameters: npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' d_tag: 'newsroom-magazine-on-imwald-by-laeserin' community_articles: true + # Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json + nip05_domain: 'blog.imwald.eu' # Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}). jumble_profile_users_base: 'https://jumble.imwald.eu/users' external_links: diff --git a/migrations/Version20260423120000.php b/migrations/Version20260423120000.php new file mode 100644 index 0000000..691efa2 --- /dev/null +++ b/migrations/Version20260423120000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 71aa5ff..30b10a1 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -8,6 +8,7 @@ use App\Entity\Article; use App\Repository\ArticleRepository; use App\Service\ArticleCommentThreadLoader; use App\Service\CacheService; +use App\Service\FeaturedAuthorSync; use App\Service\MagazineContentService; use App\Service\MagazineRefresher; use App\Service\Nip09DeletionApplier; @@ -43,6 +44,7 @@ final class PrewarmCommand extends Command private readonly ArticleCommentThreadLoader $commentThreadLoader, private readonly ParameterBagInterface $params, private readonly LoggerInterface $logger, + private readonly FeaturedAuthorSync $featuredAuthorSync, ) { parent::__construct(); } @@ -115,6 +117,15 @@ final class PrewarmCommand extends Command } } else { $io->note('Skipping magazine (--no-magazine).'); + try { + $fa = $this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories(); + if ($fa > 0) { + $io->writeln(sprintf(' Featured authors: added %d new NIP-05 row(s) from the cached category index.', $fa)); + } + } catch (\Throwable $e) { + $this->logger->warning('app:prewarm featured author sync (no-magazine)', ['e' => $e->getMessage()]); + $io->warning('Featured author sync failed: '.$e->getMessage()); + } } $io->section('Long-form in DB (category `a` tags missing from MySQL)'); diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 0ee7be4..d1060da 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -278,7 +278,13 @@ class ArticleController extends AbstractController /** * @throws InvalidArgumentException|CommonMarkException */ - #[Route('/article/d/{slug}', name: 'article-slug')] + // Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation. + #[Route( + path: '/article/d/{slug}', + name: 'article-slug', + requirements: ['slug' => '.+'], + options: ['utf8' => true], + )] public function article( $slug, EntityManagerInterface $entityManager, diff --git a/src/Controller/FeaturedAuthorsController.php b/src/Controller/FeaturedAuthorsController.php new file mode 100644 index 0000000..e76d770 --- /dev/null +++ b/src/Controller/FeaturedAuthorsController.php @@ -0,0 +1,57 @@ +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, + ]); + } +} diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php index 658b287..144551d 100644 --- a/src/Controller/SeoController.php +++ b/src/Controller/SeoController.php @@ -7,10 +7,12 @@ namespace App\Controller; use App\Entity\Article; use App\Enum\EventStatusEnum; use App\Repository\ArticleRepository; +use App\Repository\FeaturedAuthorRepository; use App\Service\MagazineContentService; use App\Service\MagazineIndexStore; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -28,6 +30,7 @@ final class SeoController extends AbstractController private readonly MagazineContentService $magazineContent, private readonly MagazineIndexStore $magazineIndexStore, private readonly ParameterBagInterface $params, + private readonly FeaturedAuthorRepository $featuredAuthorRepository, ) { } @@ -42,6 +45,8 @@ final class SeoController extends AbstractController $urls[] = ['loc' => $this->absoluteUrlForRoute('articles'), 'lastmod' => null]; } + $urls[] = ['loc' => $this->absoluteUrlForRoute('featured_authors'), 'lastmod' => null]; + foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { $urls[] = [ 'loc' => $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]), @@ -90,6 +95,65 @@ final class SeoController extends AbstractController ); } + /** + * NIP-05 well-known: maps site-assigned local-parts to hex pubkeys (featured magazine authors). + * Must not redirect. Includes recommended `relays` for clients when profile relay URLs are configured. + */ + #[Route(path: '/.well-known/nostr.json', name: 'nostr_well_known', methods: ['GET', 'HEAD'])] + public function nostrWellKnown(): JsonResponse + { + $rows = $this->featuredAuthorRepository->findAllListedOrderByLocalPart(); + $names = []; + foreach ($rows as $r) { + $names[$r->getLocalPart()] = strtolower($r->getPubkeyHex()); + } + $payload = ['names' => $names]; + $relays = $this->buildRelaysByPubkey($names); + if ($relays !== []) { + $payload['relays'] = $relays; + } + + $headers = [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'Access-Control-Allow-Origin' => '*', + 'Cache-Control' => 'public, max-age=120', + ]; + + return new JsonResponse( + $payload, + Response::HTTP_OK, + $headers + ); + } + + /** + * @param array $names local-part => hex pubkey + * + * @return array> + */ + private function buildRelaysByPubkey(array $names): array + { + $raw = $this->params->get('profile_relays'); + if (!\is_array($raw) || $raw === []) { + return []; + } + $urls = []; + foreach ($raw as $u) { + if (\is_string($u) && (str_starts_with($u, 'wss://') || str_starts_with($u, 'ws://'))) { + $urls[] = $u; + } + } + if ($urls === []) { + return []; + } + $out = []; + foreach ($names as $hex) { + $out[strtolower($hex)] = $urls; + } + + return $out; + } + #[Route('/feeds/magazine.xml', name: 'feed_magazine', methods: ['GET'])] public function feedMagazine(Request $request): Response { diff --git a/src/Entity/FeaturedAuthor.php b/src/Entity/FeaturedAuthor.php new file mode 100644 index 0000000..b183d8d --- /dev/null +++ b/src/Entity/FeaturedAuthor.php @@ -0,0 +1,89 @@ + 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; + } +} diff --git a/src/Repository/FeaturedAuthorRepository.php b/src/Repository/FeaturedAuthorRepository.php new file mode 100644 index 0000000..8323537 --- /dev/null +++ b/src/Repository/FeaturedAuthorRepository.php @@ -0,0 +1,54 @@ + + */ +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 + */ + public function findAllListedOrderByLocalPart(): array + { + return $this->createQueryBuilder('f') + ->where('f.isListed = :t') + ->setParameter('t', true) + ->orderBy('f.localPart', 'ASC') + ->getQuery() + ->getResult(); + } + +} diff --git a/src/Service/FeaturedAuthorSync.php b/src/Service/FeaturedAuthorSync.php new file mode 100644 index 0000000..0afdee7 --- /dev/null +++ b/src/Service/FeaturedAuthorSync.php @@ -0,0 +1,120 @@ +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)); + } +} diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 623f187..c779d19 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -100,6 +100,43 @@ final class MagazineContentService return array_values(array_unique($out)); } + /** + * Distinct author pubkeys (hex) from every category index `a` tag (kind:pubkey:identifier). + * + * @return list + */ + public function getAllDistinctCategoryAuthorPubkeyHexes(): array + { + $seen = []; + $out = []; + foreach ($this->getCategorySlugsFromStore() as $slug) { + $catIndex = $this->store->getCategory($slug); + if ($catIndex === null) { + continue; + } + foreach ($catIndex->getTags() as $tag) { + if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) { + continue; + } + $parts = explode(':', (string) $tag[1], 3); + if (\count($parts) < 2) { + continue; + } + $pk = strtolower((string) $parts[1]); + if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { + continue; + } + if (isset($seen[$pk])) { + continue; + } + $seen[$pk] = true; + $out[] = $pk; + } + } + + return $out; + } + /** * Title from cached category index event tags, or the slug when missing. */ diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index 31258bc..17067f0 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -23,6 +23,7 @@ final class MagazineRefresher private readonly ParameterBagInterface $params, private readonly LoggerInterface $logger, private readonly CacheItemPoolInterface $appCache, + private readonly FeaturedAuthorSync $featuredAuthorSync, ) { } @@ -105,6 +106,14 @@ final class MagazineRefresher } } + try { + $this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories(); + } catch (\Throwable $e) { + $this->logger->warning('MagazineRefresher: featured author sync failed', [ + 'message' => $e->getMessage(), + ]); + } + $this->touchLastRelayTime(); } diff --git a/templates/components/Footer.html.twig b/templates/components/Footer.html.twig index 795534b..7f05e4d 100644 --- a/templates/components/Footer.html.twig +++ b/templates/components/Footer.html.twig @@ -1,15 +1,27 @@