$this->absoluteUrlForRoute('home'), 'lastmod' => null]; if ((bool) $this->params->get('community_articles')) { $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]), 'lastmod' => null, ]; } $articles = $this->articleRepository->findPublishedForSyndication(8000); $bySlug = $this->dedupeArticlesByLatestRevision($articles); foreach ($bySlug as $article) { $loc = $this->nostrPathHelper->articleAbsoluteUrl($article); if ($loc === '') { continue; } $urls[] = [ 'loc' => $loc, 'lastmod' => $this->articleLastMod($article), ]; } $body = '' ."\n" .''; foreach ($urls as $row) { $body .= "\n \n ".$this->xmlText($row['loc']).''; if ($row['lastmod'] instanceof \DateTimeInterface) { $body .= "\n ".$row['lastmod']->format('Y-m-d').''; } $body .= "\n "; } $body .= "\n\n"; return $this->xmlResponse($body); } #[Route('/robots.txt', name: 'robots_txt', methods: ['GET'])] public function robots(): Response { $sitemap = $this->absoluteUrlForRoute('sitemap'); $txt = "User-agent: *\nAllow: /\n\nSitemap: {$sitemap}\n"; return new Response( $txt, Response::HTTP_OK, [ 'Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'public, max-age=3600', ], ); } /** * 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 { $site = (string) $this->params->get('name'); $list = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication(); $list = \array_slice($list, 0, self::FEED_MAX_ITEMS); $feedUrl = $this->absoluteUrlForRoute('feed_magazine'); $homeUrl = $this->absoluteUrlForRoute('home'); $selfId = 'urn:web:'.$this->urlHostId($request).':feed:magazine'; $updated = $this->newestArticleUpdate($list); $body = $this->buildAtomFeed( $site.': all categories', (string) $this->params->get('description'), $selfId, $feedUrl, $homeUrl, $updated, $request, $list, ); return $this->atomResponse($body); } #[Route('/feeds/cat/{slug}.xml', name: 'feed_category', methods: ['GET'])] public function feedCategory(Request $request, string $slug): Response { if ($this->magazineIndexStore->getCategory($slug) === null) { throw $this->createNotFoundException('Unknown category'); } $site = (string) $this->params->get('name'); $data = $this->magazineContent->getCategoryPageData($slug); $rawList = $data['list'] ?? []; $catTitle = (string) ($data['category']['title'] ?? $this->magazineContent->getCategoryDisplayTitle($slug)); $summary = (string) ($data['category']['summary'] ?? ''); $list = array_values( array_filter( $rawList, static function (Article $a): bool { $s = $a->getEventStatus(); if ($s === null) { return false; } return $s === EventStatusEnum::PUBLISHED || $s === EventStatusEnum::ARCHIVED; } ) ); if (\count($list) > self::FEED_MAX_ITEMS) { $list = \array_slice($list, 0, self::FEED_MAX_ITEMS); } $feedUrl = $this->absoluteUrlForRoute('feed_category', ['slug' => $slug]); $categoryPage = $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]); $selfId = 'urn:web:'.$this->urlHostId($request).':feed:cat:'.rawurlencode($slug); $title = $catTitle !== '' ? $catTitle.' — '.$site : $site; $subtitle = $summary !== '' ? $summary : (string) $this->params->get('description'); $updated = $this->newestArticleUpdate($list); $body = $this->buildAtomFeed( $title, $subtitle, $selfId, $feedUrl, $categoryPage, $updated, $request, $list, ); return $this->atomResponse($body); } private function absoluteUrlForRoute(string $name, array $params = []): string { return $this->generateUrl($name, $params, UrlGeneratorInterface::ABSOLUTE_URL); } private function urlHostId(Request $request): string { $h = $request->getHost(); return preg_replace('/[^a-zA-Z0-9.\\-]+/', '-', $h) ?? 'site'; } /** * @param list
$list */ private function buildAtomFeed( string $title, string $subtitle, string $id, string $selfUrl, string $alternateHtmlUrl, \DateTimeImmutable $updated, Request $request, array $list, ): string { $xml = '' ."\n" .'' ."\n ".$this->xmlText($title)."\n ".$this->xmlText($subtitle).""; $xml .= "\n ".$this->xmlText($id).''; $xml .= "\n xmlAttr($selfUrl)."\" rel=\"self\" type=\"application/atom+xml\"/>"; $xml .= "\n xmlAttr($alternateHtmlUrl)."\" rel=\"alternate\" type=\"text/html\"/>"; $xml .= "\n ".$this->xmlText($updated->format('c')).''; $authorName = (string) $this->params->get('name'); $xml .= "\n ".$this->xmlText($authorName)."\n unfold"; foreach ($list as $article) { $xml .= $this->atomEntryForArticle($request, $article); } $xml .= "\n\n"; return $xml; } private function atomEntryForArticle(Request $request, Article $article): string { $slug = \trim((string) $article->getSlug()); if ($slug === '') { return ''; } $permalink = $this->nostrPathHelper->articleAbsoluteUrl($article); if ($permalink === '') { return ''; } $title = (string) ($article->getTitle() ?? 'Untitled'); $tArticle = $this->articleLastMod($article); $sum = (string) ($article->getSummary() ?? ''); if ($sum === '' && $article->getContent() !== null) { $plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? ''; $sum = (string) mb_substr($plain, 0, 500); } // One stable Atom per row. Nostr eventId can repeat (revisions, duplicates); readers // merge on and would only show a single entry if ids collided. $dbId = $article->getId(); $entryId = 'urn:web:'.$this->urlHostId($request) .':db-article:'.($dbId !== null && $dbId !== '' ? (string) $dbId : \spl_object_id($article)); $pub = $article->getDisplayDateTime() ?? $tArticle; $out = "\n "; $out .= "\n ".$this->xmlText($title).""; $out .= "\n xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>"; $out .= "\n ".$this->xmlText($entryId).''; $out .= "\n ".$this->xmlText($tArticle->format('c'))."\n ".$this->xmlText($pub->format('c')).''; $out .= "\n ".$this->xmlText($this->oneLine($sum)).""; $out .= "\n "; return $out; } private function oneLine(string $s): string { return trim(preg_replace("/[\r\n]+/", ' ', $s) ?? ''); } /** * @param list
$articles * @return array */ private function dedupeArticlesByLatestRevision(array $articles): array { $bySlug = []; foreach ($articles as $article) { $slug = \trim((string) $article->getSlug()); if ($slug === '') { continue; } $c = $article->getCreatedAt(); if (!isset($bySlug[$slug])) { $bySlug[$slug] = $article; continue; } $prev = $bySlug[$slug]->getCreatedAt(); if ($c !== null && (null === $prev || $c > $prev)) { $bySlug[$slug] = $article; } } return $bySlug; } /** * @param list
$list */ private function newestArticleUpdate(array $list): \DateTimeImmutable { $t = new \DateTimeImmutable('@0'); foreach ($list as $a) { $m = $this->articleLastMod($a); if ($m > $t) { $t = $m; } } if ((int) $t->format('U') === 0) { return new \DateTimeImmutable(); } return $t; } private function articleLastMod(Article $a): \DateTimeImmutable { $p = $a->getPublishedAt(); $c = $a->getCreatedAt() ?? $p; if ($p !== null && $c !== null) { return $p > $c ? $p : $c; } return $p ?? $c ?? new \DateTimeImmutable(); } private function xmlText(string $s): string { return htmlspecialchars($this->stripInvalidXml1Chars($s), \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); } private function xmlAttr(string $s): string { return htmlspecialchars($this->stripInvalidXml1Chars($s), \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); } /** * XML 1.0 disallows C0 control chars other than tab, CR, LF; they can make feeds appear truncated * after the first entry that used only “clean” text. */ private function stripInvalidXml1Chars(string $s): string { return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $s) ?? $s; } private function xmlResponse(string $body): Response { return new Response( $body, Response::HTTP_OK, [ 'Content-Type' => 'application/xml; charset=UTF-8', 'Cache-Control' => 'public, max-age=600', ], ); } private function atomResponse(string $body): Response { return new Response( $body, Response::HTTP_OK, [ 'Content-Type' => 'application/atom+xml; charset=UTF-8', 'Cache-Control' => 'public, max-age=300', ], ); } }