From ba08bd4250e18dd5d72d45034730c1f968b5e448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 1 Sep 2025 18:52:20 +0200 Subject: [PATCH] Create a magazine --- .../nostr_index_sign_controller.js | 166 ++++++++++++ src/Controller/MagazineWizardController.php | 247 ++++++++++++++++++ src/Dto/CategoryDraft.php | 17 ++ src/Dto/MagazineDraft.php | 19 ++ src/Form/CategoryArticlesType.php | 41 +++ src/Form/CategoryType.php | 46 ++++ src/Form/MagazineSetupType.php | 64 +++++ .../NostrRawNpubParser.php | 12 +- templates/components/UserMenu.html.twig | 5 + .../magazine/magazine_articles.html.twig | 34 +++ templates/magazine/magazine_review.html.twig | 85 ++++++ templates/magazine/magazine_setup.html.twig | 31 +++ 12 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 assets/controllers/nostr_index_sign_controller.js create mode 100644 src/Controller/MagazineWizardController.php create mode 100644 src/Dto/CategoryDraft.php create mode 100644 src/Dto/MagazineDraft.php create mode 100644 src/Form/CategoryArticlesType.php create mode 100644 src/Form/CategoryType.php create mode 100644 src/Form/MagazineSetupType.php create mode 100644 templates/magazine/magazine_articles.html.twig create mode 100644 templates/magazine/magazine_review.html.twig create mode 100644 templates/magazine/magazine_setup.html.twig diff --git a/assets/controllers/nostr_index_sign_controller.js b/assets/controllers/nostr_index_sign_controller.js new file mode 100644 index 0000000..e00b51e --- /dev/null +++ b/assets/controllers/nostr_index_sign_controller.js @@ -0,0 +1,166 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['status', 'publishButton', 'computedPreview']; + static values = { + categoryEvents: String, + magazineEvent: String, + publishUrl: String, + csrfToken: String + }; + + async connect() { + try { + console.debug('[nostr-index-sign] connected'); + await this.preparePreview(); + } catch (_) {} + } + + async preparePreview() { + try { + const catSkeletons = JSON.parse(this.categoryEventsValue || '[]'); + const magSkeleton = JSON.parse(this.magazineEventValue || '{}'); + let pubkey = ''; + if (window.nostr && typeof window.nostr.getPublicKey === 'function') { + try { pubkey = await window.nostr.getPublicKey(); } catch (_) {} + } + + const categoryCoordinates = []; + for (let i = 0; i < catSkeletons.length; i++) { + const evt = catSkeletons[i]; + const slug = this.extractSlug(evt.tags); + if (slug) { + categoryCoordinates.push(`30040:${pubkey}:${slug}`); + } + } + + const previewMag = JSON.parse(JSON.stringify(magSkeleton)); + previewMag.tags = (previewMag.tags || []).filter(t => t[0] !== 'a'); + categoryCoordinates.forEach(c => previewMag.tags.push(['a', c])); + previewMag.pubkey = pubkey; + + if (this.hasComputedPreviewTarget) { + this.computedPreviewTarget.textContent = JSON.stringify(previewMag, null, 2); + } + } catch (e) { + // no-op preview errors + } + } + + async signAndPublish(event) { + event.preventDefault(); + + if (!window.nostr) { + this.showError('Nostr extension not found'); + return; + } + if (!this.publishUrlValue || !this.csrfTokenValue) { + this.showError('Missing config'); + return; + } + + this.publishButtonTarget.disabled = true; + try { + const pubkey = await window.nostr.getPublicKey(); + const catSkeletons = JSON.parse(this.categoryEventsValue || '[]'); + const magSkeleton = JSON.parse(this.magazineEventValue || '{}'); + + const categoryCoordinates = []; + + // 1) Publish each category index + for (let i = 0; i < catSkeletons.length; i++) { + const evt = catSkeletons[i]; + this.ensureCreatedAt(evt); + this.ensureContent(evt); + evt.pubkey = pubkey; + + const slug = this.extractSlug(evt.tags); + if (!slug) throw new Error('Category missing slug (d tag)'); + + this.showStatus(`Signing category ${i + 1}/${catSkeletons.length}…`); + const signed = await window.nostr.signEvent(evt); + + this.showStatus(`Publishing category ${i + 1}/${catSkeletons.length}…`); + await this.publishSigned(signed); + + // Coordinate for the category index (kind:pubkey:slug) + const coord = `30040:${pubkey}:${slug}`; + categoryCoordinates.push(coord); + } + + // 2) Build magazine event with 'a' tags referencing cats + this.showStatus('Preparing magazine index…'); + this.ensureCreatedAt(magSkeleton); + this.ensureContent(magSkeleton); + magSkeleton.pubkey = pubkey; + + // Remove any pre-existing 'a' to avoid duplicates, then add new ones + magSkeleton.tags = (magSkeleton.tags || []).filter(t => t[0] !== 'a'); + categoryCoordinates.forEach(c => magSkeleton.tags.push(['a', c])); + + // 3) Sign and publish magazine + this.showStatus('Signing magazine index…'); + const signedMag = await window.nostr.signEvent(magSkeleton); + + this.showStatus('Publishing magazine index…'); + await this.publishSigned(signedMag); + + this.showSuccess('Published magazine and categories successfully'); + + } catch (e) { + console.error(e); + this.showError(e.message || 'Publish failed'); + } finally { + this.publishButtonTarget.disabled = false; + } + } + + async publishSigned(signedEvent) { + const res = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfTokenValue, + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ event: signedEvent }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + return res.json(); + } + + extractSlug(tags) { + if (!Array.isArray(tags)) return null; + for (const t of tags) { + if (Array.isArray(t) && t[0] === 'd' && t[1]) return t[1]; + } + return null; + } + + ensureCreatedAt(evt) { + if (!evt.created_at) evt.created_at = Math.floor(Date.now() / 1000); + } + ensureContent(evt) { + if (typeof evt.content !== 'string') evt.content = ''; + } + + showStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } +} + diff --git a/src/Controller/MagazineWizardController.php b/src/Controller/MagazineWizardController.php new file mode 100644 index 0000000..6761ec5 --- /dev/null +++ b/src/Controller/MagazineWizardController.php @@ -0,0 +1,247 @@ +getDraft($request); + if (!$draft) { + $draft = new MagazineDraft(); + $draft->categories = [new CategoryDraft()]; + } + + $form = $this->createForm(MagazineSetupType::class, $draft); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $draft = $form->getData(); + // Slug generation with a short random suffix + if (!$draft->slug) { + $draft->slug = $this->slugifyWithRandom($draft->title); + } + foreach ($draft->categories as $cat) { + if (!$cat->slug) { + $cat->slug = $this->slugifyWithRandom($cat->title); + } + } + $this->saveDraft($request, $draft); + return $this->redirectToRoute('mag_wizard_articles'); + } + + return $this->render('magazine/magazine_setup.html.twig', [ + 'form' => $form->createView(), + ]); + } + + #[Route('/magazine/wizard/articles', name: 'mag_wizard_articles')] + public function articles(Request $request): Response + { + $draft = $this->getDraft($request); + if (!$draft) { + return $this->redirectToRoute('mag_wizard_setup'); + } + + // Build a form as a collection of CategoryArticlesType + $formBuilder = $this->createFormBuilder($draft); + $formBuilder->add('categories', \Symfony\Component\Form\Extension\Core\Type\CollectionType::class, [ + 'entry_type' => CategoryArticlesType::class, + 'allow_add' => false, + 'allow_delete' => false, + 'by_reference' => false, + 'label' => false, + ]); + $form = $formBuilder->getForm(); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $this->saveDraft($request, $form->getData()); + return $this->redirectToRoute('mag_wizard_review'); + } + + return $this->render('magazine/magazine_articles.html.twig', [ + 'form' => $form->createView(), + ]); + } + + #[Route('/magazine/wizard/review', name: 'mag_wizard_review')] + public function review(Request $request): Response + { + $draft = $this->getDraft($request); + if (!$draft) { + return $this->redirectToRoute('mag_wizard_setup'); + } + + // Build event skeletons (without pubkey/sig/id); created_at client can adjust + $categoryEvents = []; + foreach ($draft->categories as $cat) { + $tags = []; + $tags[] = ['d', $cat->slug]; + if ($cat->title) { $tags[] = ['title', $cat->title]; } + if ($cat->summary) { $tags[] = ['summary', $cat->summary]; } + foreach ($cat->tags as $t) { $tags[] = ['t', $t]; } + foreach ($cat->articles as $a) { + if (is_string($a) && $a !== '') { $tags[] = ['a', $a]; } + } + $categoryEvents[] = [ + 'kind' => 30040, + 'created_at' => time(), + 'tags' => $tags, + 'content' => '', + ]; + } + + // Determine current user's pubkey (hex) from their npub (user identifier) + $pubkeyHex = null; + $user = $this->getUser(); + if ($user && method_exists($user, 'getUserIdentifier')) { + try { + $key = new Key(); + $pubkeyHex = $key->convertToHex($user->getUserIdentifier()); + } catch (\Throwable $e) { + $pubkeyHex = null; + } + } + + $magTags = []; + $magTags[] = ['d', $draft->slug]; + if ($draft->title) { $magTags[] = ['title', $draft->title]; } + if ($draft->summary) { $magTags[] = ['summary', $draft->summary]; } + if ($draft->imageUrl) { $magTags[] = ['image', $draft->imageUrl]; } + if ($draft->language) { $magTags[] = ['l', $draft->language]; } + foreach ($draft->tags as $t) { $magTags[] = ['t', $t]; } + + // If we know the user's pubkey, include all category coordinates as 'a' tags now + if ($pubkeyHex) { + foreach ($draft->categories as $cat) { + if ($cat->slug) { + $magTags[] = ['a', sprintf('30040:%s:%s', $pubkeyHex, $cat->slug)]; + } + } + } + + $magazineEvent = [ + 'kind' => 30040, + 'created_at' => time(), + 'tags' => $magTags, + 'content' => '', + ]; + + return $this->render('magazine/magazine_review.html.twig', [ + 'draft' => $draft, + 'categoryEventsJson' => json_encode($categoryEvents, JSON_UNESCAPED_SLASHES), + 'magazineEventJson' => json_encode($magazineEvent, JSON_UNESCAPED_SLASHES), + 'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('nostr_publish')->getValue(), + ]); + } + + #[Route('/api/index/publish', name: 'api-index-publish', methods: ['POST'])] + public function publishIndexEvent( + Request $request, + CacheItemPoolInterface $redisCache, + CsrfTokenManagerInterface $csrfTokenManager + ): JsonResponse { + // Verify CSRF token + $csrfToken = $request->headers->get('X-CSRF-TOKEN'); + if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) { + return new JsonResponse(['error' => 'Invalid CSRF token'], 403); + } + + $data = json_decode($request->getContent(), true); + if (!$data || !isset($data['event'])) { + return new JsonResponse(['error' => 'Invalid request'], 400); + } + + $signedEvent = $data['event']; + + // Convert array to swentel Event and verify + $eventObj = new Event(); + $eventObj->setId($signedEvent['id'] ?? ''); + $eventObj->setPublicKey($signedEvent['pubkey'] ?? ''); + $eventObj->setCreatedAt($signedEvent['created_at'] ?? time()); + $eventObj->setKind($signedEvent['kind'] ?? 30040); + $eventObj->setTags($signedEvent['tags'] ?? []); + $eventObj->setContent($signedEvent['content'] ?? ''); + $eventObj->setSignature($signedEvent['sig'] ?? ''); + + if (!$eventObj->verify()) { + return new JsonResponse(['error' => 'Verification failed'], 400); + } + + // Extract slug from 'd' tag + $slug = null; + foreach ($signedEvent['tags'] as $tag) { + if (isset($tag[0]) && $tag[0] === 'd' && isset($tag[1])) { + $slug = $tag[1]; + break; + } + } + if (!$slug) { + return new JsonResponse(['error' => 'Missing d tag/slug'], 400); + } + + // Save to Redis under magazine- + try { + $key = 'magazine-' . $slug; + $item = $redisCache->getItem($key); + $item->set($eventObj); + $redisCache->save($item); + } catch (\Throwable $e) { + return new JsonResponse(['error' => 'Redis error'], 500); + } + + return new JsonResponse(['ok' => true]); + } + + #[Route('/magazine/wizard/cancel', name: 'mag_wizard_cancel', methods: ['GET'])] + public function cancel(Request $request): Response + { + $this->clearDraft($request); + $this->addFlash('info', 'Magazine setup canceled.'); + return $this->redirectToRoute('home'); + } + + private function getDraft(Request $request): ?MagazineDraft + { + $data = $request->getSession()->get(self::SESSION_KEY); + return $data instanceof MagazineDraft ? $data : null; + } + + private function saveDraft(Request $request, MagazineDraft $draft): void + { + $request->getSession()->set(self::SESSION_KEY, $draft); + } + + private function clearDraft(Request $request): void + { + $request->getSession()->remove(self::SESSION_KEY); + } + + private function slugifyWithRandom(string $title): string + { + $slug = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $title)) ?? ''); + $slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); + $rand = substr(bin2hex(random_bytes(4)), 0, 6); + return $slug !== '' ? ($slug . '-' . $rand) : $rand; + } +} diff --git a/src/Dto/CategoryDraft.php b/src/Dto/CategoryDraft.php new file mode 100644 index 0000000..1f451fe --- /dev/null +++ b/src/Dto/CategoryDraft.php @@ -0,0 +1,17 @@ +add('title', TextType::class, [ + 'label' => 'Category', + 'required' => true, + 'attr' => ['readonly' => true], + ]) + ->add('articles', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'label' => 'Article coordinates (kind:npub|pubkey:slug)', + 'prototype' => true, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CategoryDraft::class, + ]); + } +} + diff --git a/src/Form/CategoryType.php b/src/Form/CategoryType.php new file mode 100644 index 0000000..1a1a547 --- /dev/null +++ b/src/Form/CategoryType.php @@ -0,0 +1,46 @@ +add('title', TextType::class, [ + 'label' => 'Category title', + 'required' => true, + ]) + ->add('summary', TextType::class, [ + 'label' => 'Summary', + 'required' => false, + ]) + ->add('tags', TextType::class, [ + 'label' => 'Tags (comma separated)', + 'required' => false, + ]); + + $builder->get('tags')->addModelTransformer($this->transformer); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CategoryDraft::class, + ]); + } +} + diff --git a/src/Form/MagazineSetupType.php b/src/Form/MagazineSetupType.php new file mode 100644 index 0000000..ec9097e --- /dev/null +++ b/src/Form/MagazineSetupType.php @@ -0,0 +1,64 @@ +add('title', TextType::class, [ + 'label' => 'Magazine title', + 'required' => true, + ]) + ->add('summary', TextType::class, [ + 'label' => 'Description / summary', + 'required' => true, + ]) + ->add('imageUrl', TextType::class, [ + 'label' => 'Logo / image URL', + 'required' => false, + ]) + ->add('language', TextType::class, [ + 'label' => 'Language (optional)', + 'required' => false, + ]) + ->add('tags', TextType::class, [ + 'label' => 'Tags (comma separated, optional)', + 'required' => false, + ]) + ->add('categories', CollectionType::class, [ + 'entry_type' => CategoryType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'label' => false, + ]) + ; + + $builder->get('tags')->addModelTransformer($this->transformer); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => MagazineDraft::class, + ]); + } +} + diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php index b641585..e33bfca 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php @@ -30,8 +30,18 @@ readonly class NostrRawNpubParser implements InlineParserInterface $fullMatch = $inlineContext->getFullMatch(); $meta = $this->redisCacheService->getMetadata($fullMatch); + // Use shortened npub as default name, from first and last 8 characters of the fullMatch + $name = substr($fullMatch, 0, 8) . '...' . substr($fullMatch, -8); + if (!empty($meta->display_name)) { + // If we have a name, use it + $name = $meta->name; + } else if (!empty($meta->name)) { + // If we have a name, use it + $name = $meta->name; + } + // Create a new inline node for the custom link - $inlineContext->getContainer()->appendChild(new NostrMentionLink($meta->name, $fullMatch)); + $inlineContext->getContainer()->appendChild(new NostrMentionLink($name, $fullMatch)); // Advance the cursor to consume the matched part (important!) $cursor->advanceBy(strlen($fullMatch)); diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 8a80986..21e920a 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -15,6 +15,11 @@
  • Write an article
  • + {% if is_granted('ROLE_ADMIN') %} +
  • + Create a magazine +
  • + {% endif %} {#
  • #} {# {{ 'heading.createNzine'|trans }}#} {#
  • #} diff --git a/templates/magazine/magazine_articles.html.twig b/templates/magazine/magazine_articles.html.twig new file mode 100644 index 0000000..6fce662 --- /dev/null +++ b/templates/magazine/magazine_articles.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Attach Articles

    + + {{ form_start(form) }} + + {% for cat in form.categories %} +
    +
    +
    {{ form_row(cat.title) }}
    +
    + +
      + {% for art in cat.articles %} +
    • {{ form_widget(art, {attr: {placeholder: 'kind:pubkey|npub:slug'}}) }}
    • + {% endfor %} +
    + +
    +
    +
    + {% endfor %} + +
    + Cancel + +
    + + {{ form_end(form) }} +{% endblock %} diff --git a/templates/magazine/magazine_review.html.twig b/templates/magazine/magazine_review.html.twig new file mode 100644 index 0000000..01bed12 --- /dev/null +++ b/templates/magazine/magazine_review.html.twig @@ -0,0 +1,85 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Review & Sign

    + +
    +

    Review the details below. When ready, click Sign & Publish. Your NIP-07 extension will be used to sign events.

    +
    + +
    +

    Magazine

    +
      +
    • Title: {{ draft.title }}
    • +
    • Summary: {{ draft.summary }}
    • +
    • Image URL: {{ draft.imageUrl ?: '—' }}
    • +
    • Language: {{ draft.language ?: '—' }}
    • +
    • Tags: {{ draft.tags is defined and draft.tags|length ? draft.tags|join(', ') : '—' }}
    • +
    • Slug: {{ draft.slug }}
    • +
    +
    + +
    +

    Categories

    + {% if draft.categories is defined and draft.categories|length %} +
      + {% for cat in draft.categories %} +
    1. +
      + {{ cat.title }} (slug: {{ cat.slug }})
      + {{ cat.summary }}
      + Tags: {{ cat.tags is defined and cat.tags|length ? cat.tags|join(', ') : '—' }} + {% if cat.articles is defined and cat.articles|length %} +
      + Articles: +
        + {% for a in cat.articles %} +
      • {{ a }}
      • + {% endfor %} +
      +
      + {% else %} +
      No articles yet.
      + {% endif %} +
      +
    2. + {% endfor %} +
    + {% else %} +

    No categories.

    + {% endif %} +
    + +
    +
    + Show event previews (JSON) +
    +

    Category events

    +
    {{ categoryEventsJson }}
    +

    Magazine event

    +
    {{ magazineEventJson }}
    +
    +
    +
    + +
    +
    +

    Final magazine event (with category 'a' tags)

    +
    
    +    
    + +
    + Cancel + +
    + +
    +
    +{% endblock %} diff --git a/templates/magazine/magazine_setup.html.twig b/templates/magazine/magazine_setup.html.twig new file mode 100644 index 0000000..a43b37d --- /dev/null +++ b/templates/magazine/magazine_setup.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Create Magazine

    + + {{ form_start(form) }} + {{ form_row(form.title) }} + {{ form_row(form.summary) }} + {{ form_row(form.imageUrl) }} + {{ form_row(form.language) }} + {{ form_row(form.tags) }} + +

    Categories

    +
    +
      + {% for cat in form.categories %} +
    • {{ form_row(cat) }}
    • + {% endfor %} +
    + +
    + +
    + Cancel + +
    + {{ form_end(form) }} +{% endblock %}