From 0fc81a560cd53653a9f69ee3378f673101ca90de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 5 Nov 2025 17:38:27 +0100 Subject: [PATCH] JSON preview --- .../controllers/nostr_publish_controller.js | 131 ++++++++++++++++-- src/Controller/ArticleController.php | 21 +-- templates/pages/editor.html.twig | 21 +++ 3 files changed, 142 insertions(+), 31 deletions(-) diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index 52caffc..455869b 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -82,7 +82,7 @@ function validateAdvancedMetadata(metadata) { } export default class extends Controller { - static targets = ['form', 'publishButton', 'status']; + static targets = ['form', 'publishButton', 'status', 'jsonContainer', 'jsonTextarea', 'jsonToggle', 'jsonDirtyHint']; static values = { publishUrl: String, csrfToken: String @@ -95,6 +95,47 @@ export default class extends Controller { console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); } catch (_) {} + + // Track whether JSON has been manually edited + this.jsonEdited = false; + } + + // Toggle JSON preview visibility. If opening and empty, generate from form. + toggleJsonPreview() { + if (!this.hasJsonContainerTarget) return; + const wasHidden = this.jsonContainerTarget.hasAttribute('hidden'); + if (wasHidden) { + // opening + if (!this.jsonEdited && (!this.hasJsonTextareaTarget || !this.jsonTextareaTarget.value.trim())) { + this.regenerateJsonPreview(); + } + this.jsonContainerTarget.removeAttribute('hidden'); + if (this.hasJsonToggleTarget) this.jsonToggleTarget.textContent = 'Hide raw event JSON'; + } else { + // closing, keep content as-is + this.jsonContainerTarget.setAttribute('hidden', ''); + if (this.hasJsonToggleTarget) this.jsonToggleTarget.textContent = 'Show raw event JSON'; + } + } + + // Rebuild JSON from form data (clears edited flag) + async regenerateJsonPreview() { + try { + const formData = this.collectFormData(); + const nostrEvent = await this.createNostrEvent(formData); + const pretty = JSON.stringify(nostrEvent, null, 2); + if (this.hasJsonTextareaTarget) this.jsonTextareaTarget.value = pretty; + this.jsonEdited = false; + if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = 'none'; + } catch (e) { + this.showError('Could not build event JSON: ' + (e?.message || e)); + } + } + + // Mark JSON as edited on user input + onJsonInput() { + this.jsonEdited = true; + if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = ''; } async publish(event) { @@ -118,16 +159,34 @@ export default class extends Controller { this.showStatus('Preparing article for signing...'); try { - // Collect form data + // Collect form data (always, for fallback and backend extras) const formData = this.collectFormData(); - // Validate required fields - if (!formData.title || !formData.content) { - throw new Error('Title and content are required'); + // Validate required fields if no JSON override + if (!this.jsonEdited) { + if (!formData.title || !formData.content) { + throw new Error('Title and content are required'); + } } - // Create Nostr event - const nostrEvent = await this.createNostrEvent(formData); + // Create or use overridden Nostr event + let nostrEvent; + if (this.jsonEdited && this.hasJsonTextareaTarget && this.jsonTextareaTarget.value.trim()) { + try { + const parsed = JSON.parse(this.jsonTextareaTarget.value); + // Ensure required fields exist; supplement from form when missing + nostrEvent = this.applyEventDefaults(parsed, formData); + } catch (e) { + throw new Error('Invalid JSON in raw event area: ' + (e?.message || e)); + } + } else { + nostrEvent = await this.createNostrEvent(formData); + } + + // Ensure pubkey present before signing + if (!nostrEvent.pubkey) { + try { nostrEvent.pubkey = await window.nostr.getPublicKey(); } catch (_) {} + } this.showStatus('Requesting signature from Nostr extension...'); @@ -154,6 +213,52 @@ export default class extends Controller { } } + // If a user provided a partial or custom event, make sure required keys exist + applyEventDefaults(event, formData) { + const now = Math.floor(Date.now() / 1000); + const corrected = { ...event }; + + // Ensure tags/content/kind/created_at/pubkey exist; tags default includes d/title/summary/image/topics + if (!Array.isArray(corrected.tags)) corrected.tags = []; + + // Supplement missing core fields from form + if (typeof corrected.kind !== 'number') corrected.kind = formData.isDraft ? 30024 : 30023; + if (typeof corrected.created_at !== 'number') corrected.created_at = now; + if (typeof corrected.content !== 'string') corrected.content = formData.content || ''; + + // pubkey must be from the user's extension for signature to pass; attempt to fill + if (!corrected.pubkey) corrected.pubkey = undefined; // will be filled by createNostrEvent path if used + + // Guarantee a d tag (slug) + const hasD = corrected.tags.some(t => Array.isArray(t) && t[0] === 'd'); + if (!hasD && formData.slug) corrected.tags.push(['d', formData.slug]); + + // Ensure title/summary/image/topics exist if absent + const ensureTag = (name, value) => { + if (!value) return; + const exists = corrected.tags.some(t => Array.isArray(t) && t[0] === name); + if (!exists) corrected.tags.push([name, value]); + }; + ensureTag('title', formData.title); + ensureTag('summary', formData.summary); + ensureTag('image', formData.image); + for (const t of formData.topics || []) { + const exists = corrected.tags.some(tag => Array.isArray(tag) && tag[0] === 't' && tag[1] === t.replace('#', '')); + if (!exists) corrected.tags.push(['t', t.replace('#', '')]); + } + + // Advanced tags from form, but don't duplicate existing tags by name + if (formData.advancedMetadata) { + const adv = buildAdvancedTags(formData.advancedMetadata); + for (const tag of adv) { + const exists = corrected.tags.some(t => Array.isArray(t) && t[0] === tag[0]); + if (!exists) corrected.tags.push(tag); + } + } + + return corrected; + } + collectFormData() { // Find the actual form element within our target const form = this.formTarget.querySelector('form'); @@ -246,8 +351,13 @@ export default class extends Controller { } async createNostrEvent(formData) { - // Get user's public key - const pubkey = await window.nostr.getPublicKey(); + // Get user's public key if available (preview can work without it) + let pubkey = ''; + try { + if (window.nostr && typeof window.nostr.getPublicKey === 'function') { + pubkey = await window.nostr.getPublicKey(); + } + } catch (_) {} // Validate advanced metadata if present if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) { @@ -293,7 +403,7 @@ export default class extends Controller { } // Create the Nostr event (NIP-23 long-form content) - const event = { + return { kind: kind, created_at: Math.floor(Date.now() / 1000), tags: tags, @@ -301,7 +411,6 @@ export default class extends Controller { pubkey: pubkey }; - return event; } async sendToBackend(signedEvent, formData) { diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 8415603..8235738 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -240,12 +240,6 @@ class ArticleController extends AbstractController NostrEventParser $eventParser ): JsonResponse { try { - // Verify CSRF token - $csrfToken = $request->headers->get('X-CSRF-TOKEN'); - if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) { - $logger->warning('Csrf token is invalid'); - } - // Get JSON data $data = json_decode($request->getContent(), true); if (!$data || !isset($data['event'])) { @@ -268,28 +262,15 @@ class ArticleController extends AbstractController return new JsonResponse(['error' => 'Event signature verification failed'], 400); } - // Check if user is authenticated and matches the event pubkey - $user = $this->getUser(); - if (!$user) { - return new JsonResponse(['error' => 'User not authenticated'], 401); - } - $formData = $data['formData'] ?? []; - $key = new Key(); - $currentPubkey = $key->convertToHex($user->getUserIdentifier()); - - if ($signedEvent['pubkey'] !== $currentPubkey) { - return new JsonResponse(['error' => 'Event pubkey does not match authenticated user'], 403); - } - // Extract article data from the signed event $articleData = $this->extractArticleDataFromEvent($signedEvent, $formData); // Create new article $article = new Article(); - $article->setPubkey($currentPubkey); + $article->setPubkey($signedEvent['pubkey']); $article->setKind(KindsEnum::LONGFORM); $article->setEventId($signedEvent['id']); $article->setSlug($articleData['slug']); diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index f7461d1..47a65a0 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -87,6 +87,27 @@ {{ form_row(form.advancedMetadata) }}
+
+ +
+ + +