diff --git a/Dockerfile b/Dockerfile index bd0b287..c9d2505 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,7 @@ CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] # Dev FrankenPHP image FROM frankenphp_base AS frankenphp_dev -ENV APP_ENV=dev XDEBUG_MODE=off +ENV APP_ENV=dev XDEBUG_MODE=develop RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 711d2d6..524e49c 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,5 +1,6 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; import ArticleCommentsController from './controllers/article_comments_controller.js'; +import MagazineSyncController from './controllers/magazine_sync_controller.js'; const app = startStimulusApp(); @@ -9,3 +10,8 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('magazine-sync', MagazineSyncController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/magazine_sync_controller.js b/assets/controllers/magazine_sync_controller.js new file mode 100644 index 0000000..634a112 --- /dev/null +++ b/assets/controllers/magazine_sync_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus"; + +/** + * After first paint, refreshes Nostr magazine indices (server-side, ≤5s) and swaps header/body HTML. + */ +export default class extends Controller { + static targets = ["headerNav", "pageBody"]; + static values = { + page: String, + slug: String, + url: String, + }; + + connect() { + this.sync(); + } + + async sync() { + const base = this.urlValue || "/ux/magazine-sync"; + const params = new URLSearchParams(); + params.set("page", this.pageValue || "article"); + const slug = this.slugValue || ""; + if (slug !== "") { + params.set("slug", slug); + } + const url = `${base}?${params.toString()}`; + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!res.ok) { + return; + } + const data = await res.json(); + if (!data.ok) { + return; + } + if (this.hasHeaderNavTarget && data.header) { + this.headerNavTarget.outerHTML = data.header; + } + if (this.hasPageBodyTarget && data.body) { + this.pageBodyTarget.outerHTML = data.body; + } + } catch { + /* ignore network errors */ + } + } +} diff --git a/compose.override.yaml b/compose.override.yaml index a3e7f51..8d00ac0 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -13,8 +13,9 @@ services: # from the bind-mount for better performance by enabling the next line: - /app/vendor environment: + # develop: xdebug_info(), better stack traces, etc. Use debug,develop for step debugging (IDE). # See https://xdebug.org/docs/all_settings#mode - XDEBUG_MODE: "${XDEBUG_MODE:-off}" + XDEBUG_MODE: "${XDEBUG_MODE:-develop}" ports: # Defaults avoid crowded 8080/8443; override with HTTP_PORT / HTTPS_PORT in .env - "127.0.0.1:${HTTP_PORT:-9080}:80/tcp" diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml index 7091aa1..cd31b9c 100644 --- a/config/packages/asset_mapper.yaml +++ b/config/packages/asset_mapper.yaml @@ -1,5 +1,9 @@ framework: asset_mapper: + # es-module-shims + native import maps can trigger "Multiple import maps are not allowed" + # in current browsers; rely on native import map support (Chrome 89+, Firefox 108+, Safari 16.4+). + # Re-enable the polyfill for older clients: set to `es-module-shims` and add the package in importmap.php. + importmap_polyfill: false # The paths to make available to the asset mapper. paths: - assets/theme/local # Highest priority (overrides) diff --git a/config/services.yaml b/config/services.yaml index 4137d1b..831c07a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -34,7 +34,15 @@ services: App\Service\NostrClient: arguments: $defaultRelayUrl: '%default_relay%' + $articleRelayUrls: '%article_relays%' App\Twig\FooterLinksExtension: arguments: $footerLinksPath: '%footer_links%' tags: [ 'twig.extension' ] + # Nostr index snapshots: distinct key prefix from other cache.app users. + App\Service\MagazineIndexStore: + arguments: + $pool: '@cache.app' + App\Service\MagazineRefresher: + arguments: + $appCache: '@cache.app' diff --git a/config/unfold.yaml b/config/unfold.yaml index 62a1a02..627e4aa 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -9,6 +9,13 @@ parameters: og_subheading: 'Imwald Blog by Laeserin' default_relay: 'wss://TheForest.nostr1.com' + # Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion), + # and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored. + article_relays: ['wss://christpill.nostr1.com', 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', 'wss://nostr.sovbit.host'] + # Example: + # article_relays: + # - 'wss://nos.lol' + # - 'wss://relay.ditto.pub' theme: 'imwald' theme_color: '#8c2f1c' diff --git a/frankenphp/conf.d/10-app.ini b/frankenphp/conf.d/10-app.ini index 79a17dd..6851d00 100644 --- a/frankenphp/conf.d/10-app.ini +++ b/frankenphp/conf.d/10-app.ini @@ -1,4 +1,6 @@ expose_php = 0 +; Default 128M is tight for long-form sync (large event JSON + Doctrine). Chunking helps; this adds headroom. +memory_limit = 256M date.timezone = UTC apc.enable_cli = 1 session.use_strict_mode = 1 diff --git a/frankenphp/conf.d/20-app.dev.ini b/frankenphp/conf.d/20-app.dev.ini index e50f43d..d7c5ca1 100644 --- a/frankenphp/conf.d/20-app.dev.ini +++ b/frankenphp/conf.d/20-app.dev.ini @@ -2,4 +2,6 @@ ; See https://github.com/docker/for-linux/issues/264 ; The `client_host` below may optionally be replaced with `discover_client_host=yes` ; Add `start_with_request=yes` to start debug session on each request +; develop is required for xdebug_info() and related helpers. Docker sets XDEBUG_MODE (overrides this). +xdebug.mode = develop xdebug.client_host = host.docker.internal diff --git a/importmap.php b/importmap.php index 1d060b8..0dd06cc 100644 --- a/importmap.php +++ b/importmap.php @@ -57,7 +57,4 @@ return [ 'version' => '2.0.3', 'type' => 'css', ], - 'es-module-shims' => [ - 'version' => '2.0.10', - ], ]; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index fb16737..799cef7 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -378,6 +378,7 @@ class ArticleController extends AbstractController return $this->render('pages/category.html.twig', [ 'category' => $category, 'list' => $articles, + 'sync_slug' => '', ]); } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 9145b04..611ea6c 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -4,148 +4,37 @@ declare(strict_types=1); namespace App\Controller; -use App\Repository\ArticleRepository; -use App\Service\NostrClient; +use App\Service\MagazineContentService; use Exception; -use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; -use Psr\Log\LoggerInterface; class DefaultController extends AbstractController { public function __construct( - private readonly CacheInterface $cache, - private readonly NostrClient $nostrClient, - private readonly ParameterBagInterface $params - ) {} + private readonly MagazineContentService $magazineContent, + ) { + } - /** - * @throws Exception - * @throws InvalidArgumentException - */ #[Route('/', name: 'home')] public function index(): Response { - $npub = $this->params->get('npub'); - $dTag = $this->params->get('d_tag'); - // Key must match {@see Header}. Throw from the cache callback when the index is missing so `null` - // is not stored under this key (same pattern as {@see CategoryLink} / per-category cache). - $cacheKey = 'magazine_root_v2_'.$dTag; - try { - $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) { - $item->expiresAfter(300); - $mag = $this->nostrClient->getMagazineIndex($npub, $dTag); - if ($mag === null) { - throw new \RuntimeException('Magazine root index not found for '.$dTag); - } - - return $mag; - }); - } catch (\Throwable) { - return $this->render('home.html.twig', [ - 'indices' => [], - ]); - } - - if ($mag === null) { - return $this->render('home.html.twig', [ - 'indices' => [], - ]); - } - - $tags = $mag->getTags(); - - $cats = array_filter($tags, function($tag) { - return ($tag[0] === 'a'); - }); - return $this->render('home.html.twig', [ - 'indices' => array_values($cats) + 'indices' => $this->magazineContent->getHomeCategoryIndexTags(), ]); } - - /** - * @throws InvalidArgumentException - */ #[Route('/cat/{slug}', name: 'magazine-category')] - public function magCategory($slug, ArticleRepository $articleRepository, LoggerInterface $logger): Response + public function magCategory(string $slug): Response { - $npub = $this->params->get('npub'); - $cacheKey = 'magazine-' . $slug; - try { - $catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) { - $item->expiresAfter(300); // 5 minutes - $mag = $this->nostrClient->getMagazineIndex($npub, $slug); - if ($mag === null) { - throw new \RuntimeException('Category index not found for '.$slug); - } - - return $mag; - }); - } catch (\Throwable) { - $catIndex = null; - } - $list = []; - $coordinates = []; - $category = []; - if ($catIndex) { - foreach ($catIndex->getTags() as $tag) { - if ($tag[0] === 'title') { - $category['title'] = $tag[1]; - } - if ($tag[0] === 'summary') { - $category['summary'] = $tag[1]; - } - if ($tag[0] === 'a') { - $coordinates[] = $tag[1]; - } - } - } - - if (!empty($coordinates)) { - $slugs = array_map(static function ($coordinate) { - $parts = explode(':', (string) $coordinate, 3); - - return trim((string) end($parts)); - }, $coordinates); - $slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== '')); - $articles = $articleRepository->findBySlugsCriteria($slugs); - $slugMap = []; - foreach ($articles as $item) { - $slug = trim((string) $item->getSlug()); - if ($slug !== '') { - if (!isset($slugMap[$slug])) { - $slugMap[$slug] = $item; - } else { - $existingItem = $slugMap[$slug]; - if ($item->getCreatedAt() > $existingItem->getCreatedAt()) { - $slugMap[$slug] = $item; - } - } - } - } - foreach ($coordinates as $coordinate) { - $parts = explode(':', (string) $coordinate, 3); - $slugKey = trim((string) end($parts)); - if ($slugKey !== '' && isset($slugMap[$slugKey])) { - $list[] = $slugMap[$slugKey]; - } - } - } - - $category['title'] = $category['title'] ?? ''; - $category['summary'] = $category['summary'] ?? ''; + $data = $this->magazineContent->getCategoryPageData($slug); return $this->render('pages/category.html.twig', [ - 'list' => $list, - 'category' => $category + 'list' => $data['list'], + 'category' => $data['category'], + 'sync_slug' => $slug, ]); } @@ -165,18 +54,19 @@ class DefaultController extends AbstractController $embed = new \Embed\Embed(); $info = $embed->get($url); if (!$info) { - throw new \Exception('No OG data found'); + throw new Exception('No OG data found'); } + return $this->render('components/Molecules/OgPreview.html.twig', [ 'og' => [ 'title' => $info->title, 'description' => $info->description, 'image' => $info->image, - 'url' => $url - ] + 'url' => $url, + ], ]); - } catch (\Exception $e) { - return new Response('
Unable to load OG preview for ' . htmlspecialchars($url) . '
', 200); + } catch (Exception $e) { + return new Response('
Unable to load OG preview for '.htmlspecialchars($url).'
', 200); } } } diff --git a/src/Controller/MagazineSyncController.php b/src/Controller/MagazineSyncController.php new file mode 100644 index 0000000..b8272aa --- /dev/null +++ b/src/Controller/MagazineSyncController.php @@ -0,0 +1,106 @@ +query->get('page', 'article'); + if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) { + $page = 'article'; + } + $slug = (string) $request->query->get('slug', ''); + + $prefer = $slug !== '' ? [$slug] : []; + + try { + $this->refresher->refreshFromRelays(8, $prefer); + } catch (\Throwable $e) { + $this->logger->warning('MagazineSyncController: refresh failed', [ + 'message' => $e->getMessage(), + 'exception' => $e, + ]); + + return new JsonResponse( + ['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()], + Response::HTTP_OK + ); + } + + $community = (bool) $this->params->get('community_articles'); + $tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(); + $globals = [ + 'magazine_community_articles' => $community, + ]; + + $header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [ + 'cats' => $tags, + ])); + + $body = null; + if ($page === 'home') { + $body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [ + 'indices' => $tags, + ])); + } elseif ($page === 'category' && $slug !== '') { + $data = $this->magazineContent->getCategoryPageData($slug); + $body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [ + 'list' => $data['list'], + 'category' => $data['category'], + ])); + } elseif ($page === 'articles') { + $body = null; + } + + return new JsonResponse([ + 'ok' => true, + 'header' => $header, + 'body' => $body, + ]); + } catch (\Throwable $e) { + $this->logger->error('MagazineSyncController: unexpected failure', [ + 'message' => $e->getMessage(), + 'exception' => $e, + ]); + + return new JsonResponse( + [ + 'ok' => false, + 'error' => 'server_error', + 'message' => 'Magazine UI sync could not be rendered.', + ], + Response::HTTP_OK + ); + } + } +} diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php new file mode 100644 index 0000000..566ab74 --- /dev/null +++ b/src/Service/MagazineContentService.php @@ -0,0 +1,163 @@ +> + */ + public function getHomeCategoryIndexTags(): array + { + $npub = (string) $this->params->get('npub'); + $dTag = (string) $this->params->get('d_tag'); + if ($this->store->getRoot($npub, $dTag) === null) { + $this->refresher->refreshFromRelays(8, []); + } elseif ($this->shouldRevalidateRootFromRelay()) { + $this->refresher->refreshFromRelays(8, []); + } + + return $this->getHomeCategoryAIndexTagsFromStoreOnly(); + } + + /** + * Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync + * has already called {@see MagazineRefresher::refreshFromRelays}. + * + * @return list> + */ + public function getHomeCategoryAIndexTagsFromStoreOnly(): array + { + return $this->categoryATagsFromStoredRoot(); + } + + /** + * @return list> + */ + private function categoryATagsFromStoredRoot(): array + { + $npub = (string) $this->params->get('npub'); + $dTag = (string) $this->params->get('d_tag'); + $mag = $this->store->getRoot($npub, $dTag); + + return $this->categoryATagsFromMag($mag); + } + + /** + * @return list> + */ + private function categoryATagsFromMag(?Event $mag): array + { + if ($mag === null) { + return []; + } + $tags = $mag->getTags(); + $cats = array_filter($tags, static function (mixed $tag): bool { + return \is_array($tag) && ($tag[0] ?? null) === 'a'; + }); + + return array_values($cats); + } + + private function shouldRevalidateRootFromRelay(): bool + { + $age = $this->refresher->getSecondsSinceLastRelayRun(); + if ($age === null) { + return true; + } + + return $age > self::ROOT_REVALIDATE_SECONDS; + } + + /** + * @return array{list: list
, category: array{title: string, summary: string}} + */ + public function getCategoryPageData(string $slug): array + { + $catIndex = $this->store->getCategory($slug); + if ($catIndex === null) { + $this->refresher->refreshFromRelays(8, [$slug]); + $catIndex = $this->store->getCategory($slug); + } + $list = []; + $coordinates = []; + $category = []; + if ($catIndex) { + foreach ($catIndex->getTags() as $tag) { + if ($tag[0] === 'title') { + $category['title'] = (string) $tag[1]; + } + if ($tag[0] === 'summary') { + $category['summary'] = (string) $tag[1]; + } + if ($tag[0] === 'a') { + $coordinates[] = $tag[1]; + } + } + } + + if (!empty($coordinates)) { + $slugs = array_map(static function ($coordinate) { + $parts = explode(':', (string) $coordinate, 3); + + return trim((string) end($parts)); + }, $coordinates); + $slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== '')); + $articles = $this->articleRepository->findBySlugsCriteria($slugs); + $slugMap = []; + foreach ($articles as $item) { + $s = trim((string) $item->getSlug()); + if ($s !== '') { + if (!isset($slugMap[$s])) { + $slugMap[$s] = $item; + } else { + $existingItem = $slugMap[$s]; + if ($item->getCreatedAt() > $existingItem->getCreatedAt()) { + $slugMap[$s] = $item; + } + } + } + } + foreach ($coordinates as $coordinate) { + $parts = explode(':', (string) $coordinate, 3); + $slugKey = trim((string) end($parts)); + if ($slugKey !== '' && isset($slugMap[$slugKey])) { + $list[] = $slugMap[$slugKey]; + } + } + } + + $category['title'] = $category['title'] ?? ''; + $category['summary'] = $category['summary'] ?? ''; + + return [ + 'list' => $list, + 'category' => $category, + ]; + } +} diff --git a/src/Service/MagazineIndexStore.php b/src/Service/MagazineIndexStore.php new file mode 100644 index 0000000..8c6d1ed --- /dev/null +++ b/src/Service/MagazineIndexStore.php @@ -0,0 +1,93 @@ +pool->getItem($this->rootKey($npub, $dTag)); + if (!$item->isHit()) { + return null; + } + + return $this->unwrap($item->get()); + } + + public function getCategory(string $slug): ?Event + { + if ($slug === '') { + return null; + } + $item = $this->pool->getItem(self::CAT_PREFIX.$slug); + if (!$item->isHit()) { + return null; + } + + return $this->unwrap($item->get()); + } + + /** + * @throws InvalidArgumentException + */ + public function putRoot(string $npub, string $dTag, Event $event): void + { + $item = $this->pool->getItem($this->rootKey($npub, $dTag)); + $item->set(serialize($event)); + $item->expiresAfter(self::PERSIST_TTL); + $this->pool->save($item); + } + + /** + * @throws InvalidArgumentException + */ + public function putCategory(string $slug, Event $event): void + { + if ($slug === '') { + return; + } + $item = $this->pool->getItem(self::CAT_PREFIX.$slug); + $item->set(serialize($event)); + $item->expiresAfter(self::PERSIST_TTL); + $this->pool->save($item); + } + + private function rootKey(string $npub, string $dTag): string + { + return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag); + } + + private function unwrap(mixed $value): ?Event + { + if (!\is_string($value) || $value === '') { + return null; + } + $e = unserialize($value, ['allowed_classes' => [Event::class]]); + if (!$e instanceof Event) { + return null; + } + + return $e; + } +} diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php new file mode 100644 index 0000000..2636603 --- /dev/null +++ b/src/Service/MagazineRefresher.php @@ -0,0 +1,165 @@ +params->get('npub'); + $dTag = (string) $this->params->get('d_tag'); + + // Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so + // after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O + // (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work. + $this->applyExecutionTimeCap($budgetSeconds); + + $root = $this->nostrClient->getMagazineIndex($npub, $dTag); + if ($root === null) { + $this->logger->warning('MagazineRefresher: root index not returned from relay', [ + 'd_tag' => $dTag, + ]); + + return; + } + + $this->store->putRoot($npub, $dTag, $root); + + $slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs); + foreach ($slugs as $slug) { + if (microtime(true) >= $deadline) { + $this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [ + 'unprocessed_from' => $slug, + ]); + break; + } + try { + $cat = $this->nostrClient->getMagazineIndex($npub, $slug); + if ($cat !== null) { + $this->store->putCategory($slug, $cat); + } + } catch (\Throwable $e) { + $this->logger->error('MagazineRefresher: category fetch failed', [ + 'slug' => $slug, + 'message' => $e->getMessage(), + ]); + } + } + + $this->touchLastRelayTime(); + } + + /** + * @throws InvalidArgumentException + */ + public function getSecondsSinceLastRelayRun(): ?int + { + try { + $item = $this->appCache->getItem(self::RELAY_STAMP_KEY); + } catch (InvalidArgumentException) { + return null; + } + if (!$item->isHit()) { + return null; + } + + return time() - (int) $item->get(); + } + + /** + * Child category indices are kind 30040; each root "a" tag is a NIP-33 address + * kind:hexpubkey:d-identifier. The third segment is the child #d (e.g. the long + * newsroom-…-category-… string), not a shortened title. + * + * @return list + */ + private function categorySlugsFromRoot(Event $root): array + { + $slugs = []; + foreach ($root->getTags() as $tag) { + if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) { + continue; + } + $parts = explode(':', (string) $tag[1], 3); + if (\count($parts) < 3) { + continue; + } + $s = trim((string) end($parts)); + if ($s !== '' && !\in_array($s, $slugs, true)) { + $slugs[] = $s; + } + } + + return $slugs; + } + + /** + * @param list $allFromRoot + * @param list $prefer + * @return list + */ + private function orderedCategorySlugs(array $allFromRoot, array $prefer): array + { + $prefer = array_values(array_filter($prefer, static function (string $s): bool { + return $s !== ''; + })); + $out = $prefer; + foreach ($allFromRoot as $s) { + if (!\in_array($s, $out, true)) { + $out[] = $s; + } + } + + return $out; + } + + /** + * @throws InvalidArgumentException + */ + private function touchLastRelayTime(): void + { + $item = $this->appCache->getItem(self::RELAY_STAMP_KEY); + $item->set((string) time()); + $item->expiresAfter(86_400); + $this->appCache->save($item); + } + + /** + * One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft + * $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time. + */ + private function applyExecutionTimeCap(int $budgetSeconds): void + { + $sec = max(30, min(120, $budgetSeconds + 30)); + @set_time_limit($sec); + @ini_set('max_execution_time', (string) $sec); + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 923de4f..0884f50 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -26,6 +26,9 @@ class NostrClient { private RelaySet $defaultRelaySet; + /** + * @param list $articleRelayUrls extra relays for the default set (default_relay is always first) + */ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ManagerRegistry $managerRegistry, @@ -33,26 +36,56 @@ class NostrClient private readonly TokenStorageInterface $tokenStorage, private readonly LoggerInterface $logger, private readonly string $defaultRelayUrl, + private readonly array $articleRelayUrls, private readonly CacheInterface $relayQueryCache, ) { - $this->defaultRelaySet = new RelaySet(); - $this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl)); + $this->defaultRelaySet = $this->buildArticleRelaySet(); } /** - * Build a fresh relay set: default relay plus optional extras (deduped). - * Never reuse {@see $defaultRelaySet} as a mutable base — that used to append relays - * onto the singleton forever and multiplied every nostr request latency. + * default_relay + article_relays from config, in order, deduplicated. Used for the static + * default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}. + * + * @return list */ - private function createRelaySet(array $relayUrls): RelaySet + private function configuredArticleRelayUrlList(): array { - $relaySet = new RelaySet(); $seen = []; - foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) { - if (!\is_string($relayUrl) || $relayUrl === '') { + $out = []; + foreach (array_merge([$this->defaultRelayUrl], $this->articleRelayUrls) as $url) { + if (!\is_string($url) || $url === '' || isset($seen[$url])) { continue; } - if (isset($seen[$relayUrl])) { + $seen[$url] = true; + $out[] = $url; + } + if ($out === []) { + $out[] = $this->defaultRelayUrl; + } + + return $out; + } + + private function buildArticleRelaySet(): RelaySet + { + $relaySet = new RelaySet(); + foreach ($this->configuredArticleRelayUrlList() as $url) { + $relaySet->addRelay(new Relay($url)); + } + + return $relaySet; + } + + /** + * Merges all configured article relays (default + article_relays) with the given URLs in order, deduped. + * Used for comment threads (getArticleDiscussion), per-author fetches, etc. + */ + private function createRelaySet(array $relayUrls): RelaySet + { + $relaySet = new RelaySet(); + $seen = []; + foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) { + if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) { continue; } $seen[$relayUrl] = true; @@ -153,16 +186,14 @@ class NostrClient $request = new Request($relays, $requestMessage); - $response = $request->send(); - // response is an n-dimensional array, where n is the number of relays in the set - // check that response has events in the results - foreach ($response as $relayRes) { - $filtered = array_filter($relayRes, function ($item) { - return $item->type === 'EVENT'; - }); - if (count($filtered) > 0) { - $this->saveLongFormContent($filtered); - } + $wrappers = $this->processResponse($request->send(), function (object $event) { + $w = new \stdClass(); + $w->event = $event; + + return $w; + }); + if ($wrappers !== []) { + $this->saveLongFormContent($wrappers); } // TODO handle relays that require auth } @@ -180,37 +211,52 @@ class NostrClient return $relaySet->send(); } + /** + * Backfill long-form (NIP-23) in time windows so relay responses and PHP stay bounded (avoids + * OOM on year-wide queries with many relays). ~60 days per step (≈2 months). + */ + private const LONGFORM_BACKFILL_CHUNK_SECONDS = 5184000; // 60 days + /** * Long-form Content * NIP-23 */ public function getLongFormContent($from = null, $to = null): void + { + $toTs = $to !== null ? (int) $to : time(); + $fromTs = $from !== null ? (int) $from : strtotime('-1 week'); + if ($fromTs >= $toTs) { + return; + } + + $chunk = self::LONGFORM_BACKFILL_CHUNK_SECONDS; + for ($windowFrom = $fromTs; $windowFrom < $toTs; $windowFrom += $chunk) { + $windowTo = min($windowFrom + $chunk, $toTs); + $this->getLongFormContentForTimeWindow($windowFrom, $windowTo); + $this->entityManager->clear(); + } + } + + private function getLongFormContentForTimeWindow(int $since, int $until): void { $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $filter = new Filter(); $filter->setKinds([KindsEnum::LONGFORM]); - $filter->setSince(strtotime('-1 week')); // default - if ($from !== null) { - $filter->setSince($from); - } - if ($to !== null) { - $filter->setUntil($to); - } + $filter->setSince($since); + $filter->setUntil($until); $requestMessage = new RequestMessage($subscriptionId, [$filter]); $request = new Request($this->defaultRelaySet, $requestMessage); - $response = $request->send(); - // response is an n-dimensional array, where n is the number of relays in the set - // check that response has events in the results - foreach ($response as $relayRes) { - $filtered = array_filter($relayRes, function ($item) { - return $item->type === 'EVENT'; - }); - if (count($filtered) > 0) { - $this->saveLongFormContent($filtered); - } + $wrappers = $this->processResponse($request->send(), function (object $event) { + $w = new \stdClass(); + $w->event = $event; + + return $w; + }); + if ($wrappers !== []) { + $this->saveLongFormContent($wrappers); } } @@ -541,11 +587,8 @@ class NostrClient { $seen = []; $out = []; - foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) { - if (!\is_string($relayUrl) || $relayUrl === '') { - continue; - } - if (isset($seen[$relayUrl])) { + foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) { + if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) { continue; } $seen[$relayUrl] = true; @@ -963,9 +1006,8 @@ class NostrClient // Continue with default relays } - // If no author relays found, add default relay if (empty($relayList)) { - $relayList = [$this->defaultRelayUrl]; + $relayList = []; } // Ensure we use a RelaySet @@ -1237,6 +1279,10 @@ class NostrClient /** * Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity} * so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass). + * + * The magazine root uses the site d_tag from config. Each category uses the full child d + * (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not + * further nested 30040 indices. */ public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity { diff --git a/src/Twig/Components/Header.php b/src/Twig/Components/Header.php index ebb0334..c19cc09 100644 --- a/src/Twig/Components/Header.php +++ b/src/Twig/Components/Header.php @@ -4,11 +4,7 @@ declare(strict_types=1); namespace App\Twig\Components; -use App\Service\NostrClient; -use Psr\Cache\InvalidArgumentException; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; +use App\Service\MagazineContentService; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -16,45 +12,9 @@ class Header { public array $cats; - /** - * @throws InvalidArgumentException - */ public function __construct( - private readonly CacheInterface $cache, - private readonly ParameterBagInterface $params, - private readonly NostrClient $nostrClient, + private readonly MagazineContentService $magazineContent, ) { - $dTag = (string) $this->params->get('d_tag'); - $npub = (string) $this->params->get('npub'); - // Same key as {@see DefaultController::index()}. If the relay returns nothing, throw from the - // callback so Symfony does not persist `null` — otherwise categories vanish until TTL (~5 min). - $cacheKey = 'magazine_root_v2_'.$dTag; - try { - $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) { - $item->expiresAfter(300); - $mag = $this->nostrClient->getMagazineIndex($npub, $dTag); - if ($mag === null) { - throw new \RuntimeException('Magazine root index not found for '.$dTag); - } - - return $mag; - }); - } catch (\Throwable) { - $this->cats = []; - - return; - } - - if ($mag === null) { - $this->cats = []; - - return; - } - - $tags = $mag->getTags(); - - $this->cats = array_filter($tags, static function ($tag): bool { - return ($tag[0] ?? null) === 'a'; - }); + $this->cats = $this->magazineContent->getHomeCategoryIndexTags(); } } diff --git a/src/Twig/Components/Molecules/CategoryLink.php b/src/Twig/Components/Molecules/CategoryLink.php index f53f847..c75d88d 100644 --- a/src/Twig/Components/Molecules/CategoryLink.php +++ b/src/Twig/Components/Molecules/CategoryLink.php @@ -2,10 +2,7 @@ namespace App\Twig\Components\Molecules; -use App\Service\NostrClient; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; +use App\Service\MagazineIndexStore; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -16,9 +13,7 @@ final class CategoryLink public string $slug = ''; public function __construct( - private readonly CacheInterface $cache, - private readonly ParameterBagInterface $params, - private readonly NostrClient $nostrClient, + private readonly MagazineIndexStore $store, ) { } @@ -34,31 +29,14 @@ final class CategoryLink } $this->title = $this->slug; - $npub = (string) $this->params->get('npub'); - // Same cache key/TTL as DefaultController::magCategory(); load from relay on miss (not read-only). - // The cache callback must return data on miss; otherwise the homepage shows raw d-tags. - try { - $cat = $this->cache->get('magazine-' . $this->slug, function (ItemInterface $item) use ($npub) { - $item->expiresAfter(300); - $mag = $this->nostrClient->getMagazineIndex($npub, $this->slug); - if ($mag === null) { - // Do not persist null: FeaturedList would get a cache hit and call getTags() on null. - throw new \RuntimeException('Category index not found for '.$this->slug); - } - - return $mag; - }); - } catch (\Throwable) { - return; - } - + $cat = $this->store->getCategory($this->slug); if (!\is_object($cat) || !\method_exists($cat, 'getTags')) { return; } $tags = $cat->getTags(); - $titleTags = array_filter($tags, static function ($tag): bool { - return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]); + $titleTags = array_filter($tags, static function (mixed $tag): bool { + return \is_array($tag) && ($tag[0] ?? null) === 'title' && isset($tag[1]); }); $first = array_key_first($titleTags); if ($first !== null) { diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index a0ae92a..adac34d 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -3,11 +3,8 @@ namespace App\Twig\Components\Organisms; use App\Repository\ArticleRepository; -use App\Service\NostrClient; +use App\Service\MagazineIndexStore; use Psr\Cache\InvalidArgumentException; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -20,10 +17,8 @@ final class FeaturedList public array $list = []; public function __construct( - private readonly CacheInterface $cache, + private readonly MagazineIndexStore $store, private readonly ArticleRepository $articleRepository, - private readonly NostrClient $nostrClient, - private readonly ParameterBagInterface $params, ) { } @@ -43,22 +38,8 @@ final class FeaturedList } $slug = $parts[2]; - $npub = (string) $this->params->get('npub'); - - try { - $catIndex = $this->cache->get('magazine-' . $slug, function (ItemInterface $item) use ($npub, $slug) { - $item->expiresAfter(300); - $mag = $this->nostrClient->getMagazineIndex($npub, $slug); - if ($mag === null) { - throw new \RuntimeException('Category index not found for '.$slug); - } - - return $mag; - }); - } catch (\Throwable) { - return; - } + $catIndex = $this->store->getCategory($slug); if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) { return; } diff --git a/templates/base.html.twig b/templates/base.html.twig index 7699e3b..c687b11 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -29,7 +29,12 @@ {% endblock %} - + diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig index f24b3f6..7306afc 100644 --- a/templates/components/Header.html.twig +++ b/templates/components/Header.html.twig @@ -11,9 +11,9 @@
-
    +
      {% for category in cats %} -
    • +
    • {% endfor %} {% if magazine_community_articles %}
    • diff --git a/templates/home.html.twig b/templates/home.html.twig index a21b271..902c782 100644 --- a/templates/home.html.twig +++ b/templates/home.html.twig @@ -1,5 +1,7 @@ {% extends 'base.html.twig' %} +{% block magazine_sync_page %}home{% endblock %} + {% block ogtags %} {% set _og_image = absolute_url(asset('og-image.jpg')) %} @@ -18,10 +20,11 @@ {% endblock %} {% block body %} - {# content #} - {% for item in indices %} - - {% endfor %} +
      + {% for item in indices %} + + {% endfor %} +
      {% endblock %} {% block aside %} diff --git a/templates/pages/category.html.twig b/templates/pages/category.html.twig index cee64fa..ac80654 100644 --- a/templates/pages/category.html.twig +++ b/templates/pages/category.html.twig @@ -1,5 +1,8 @@ {% extends 'base.html.twig' %} +{% block magazine_sync_page %}{% if app.request.attributes.get('_route') == 'articles' %}articles{% else %}category{% endif %}{% endblock %} +{% block magazine_sync_slug %}{{ (sync_slug|default(''))|e('html_attr') }}{% endblock %} + {% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %} {% block meta_description %} @@ -13,11 +16,11 @@ {% set _og_image = absolute_url(asset('og-image.jpg')) %} - + - + @@ -28,7 +31,9 @@ {% endblock %} {% block body %} - +
      + +
      {% endblock %} {% block aside %} diff --git a/templates/ux/magazine/category_body.html.twig b/templates/ux/magazine/category_body.html.twig new file mode 100644 index 0000000..0c1cdd1 --- /dev/null +++ b/templates/ux/magazine/category_body.html.twig @@ -0,0 +1,3 @@ +
      + +
      diff --git a/templates/ux/magazine/header_ul.html.twig b/templates/ux/magazine/header_ul.html.twig new file mode 100644 index 0000000..86784a6 --- /dev/null +++ b/templates/ux/magazine/header_ul.html.twig @@ -0,0 +1,10 @@ +
        + {% for category in cats %} +
      • + {% endfor %} + {% if magazine_community_articles %} +
      • + Latest Articles +
      • + {% endif %} +
      diff --git a/templates/ux/magazine/home_body.html.twig b/templates/ux/magazine/home_body.html.twig new file mode 100644 index 0000000..549f227 --- /dev/null +++ b/templates/ux/magazine/home_body.html.twig @@ -0,0 +1,5 @@ +
      + {% for item in indices %} + + {% endfor %} +