7 changed files with 283 additions and 12 deletions
@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
// NIP-22 Comment Publishing Controller
|
||||
// Usage: Attach to a form with data attributes for root/parent context
|
||||
export default class extends Controller { |
||||
static targets = ['publishButton', 'status']; |
||||
static values = { |
||||
publishUrl: String, |
||||
csrfToken: String |
||||
}; |
||||
|
||||
connect() { |
||||
console.log('Nostr comment controller connected'); |
||||
try { |
||||
console.debug('[nostr-comment] publishUrl:', this.publishUrlValue || '(none)'); |
||||
console.debug('[nostr-comment] has csrfToken:', Boolean(this.csrfTokenValue)); |
||||
} catch (_) {} |
||||
} |
||||
|
||||
async publish(event) { |
||||
event.preventDefault(); |
||||
|
||||
if (!this.publishUrlValue) { |
||||
this.showError('Publish URL is not configured'); |
||||
return; |
||||
} |
||||
if (!this.csrfTokenValue) { |
||||
this.showError('Missing CSRF token'); |
||||
return; |
||||
} |
||||
if (!window.nostr) { |
||||
this.showError('Nostr extension not found'); |
||||
return; |
||||
} |
||||
|
||||
this.publishButtonTarget.disabled = true; |
||||
this.showStatus('Preparing comment for signing...'); |
||||
|
||||
try { |
||||
// Collect form data and context
|
||||
const formData = this.collectFormData(); |
||||
|
||||
// Validate required fields
|
||||
if (!formData.content) { |
||||
throw new Error('Comment content is required'); |
||||
} |
||||
if (!formData.root || !formData.parent) { |
||||
throw new Error('Missing root or parent context'); |
||||
} |
||||
if (!this.isPlaintext(formData.content)) { |
||||
throw new Error('Comment must be plaintext (no formatting)'); |
||||
} |
||||
|
||||
// Create NIP-22 event
|
||||
const nostrEvent = await this.createNip22Event(formData); |
||||
|
||||
this.showStatus('Requesting signature from Nostr extension...'); |
||||
const signedEvent = await window.nostr.signEvent(nostrEvent); |
||||
|
||||
this.showStatus('Publishing comment...'); |
||||
await this.sendToBackend(signedEvent, formData); |
||||
|
||||
this.showSuccess('Comment published successfully!'); |
||||
// Optionally reload or clear form
|
||||
setTimeout(() => { |
||||
window.location.reload(); |
||||
}, 1500); |
||||
} catch (error) { |
||||
console.error('Publishing error:', error); |
||||
this.showError(`Publishing failed: ${error.message}`); |
||||
} finally { |
||||
this.publishButtonTarget.disabled = false; |
||||
} |
||||
} |
||||
|
||||
collectFormData() { |
||||
// Use the form element directly (this.element is the <form>)
|
||||
const form = this.element; |
||||
if (!form) { |
||||
throw new Error('Form element not found'); |
||||
} |
||||
const formData = new FormData(form); |
||||
// Comment content (plaintext only)
|
||||
const content = (formData.get('comment[content]') || '').trim(); |
||||
// Root and parent context (JSON in hidden fields or data attributes)
|
||||
let root, parent; |
||||
try { |
||||
root = JSON.parse(formData.get('comment[root]') || this.element.dataset.root || '{}'); |
||||
parent = JSON.parse(formData.get('comment[parent]') || this.element.dataset.parent || '{}'); |
||||
} catch (_) { |
||||
throw new Error('Invalid root/parent context'); |
||||
} |
||||
return { content, root, parent }; |
||||
} |
||||
|
||||
isPlaintext(text) { |
||||
// No HTML tags, no Markdown formatting
|
||||
return !(/[<>\*\_\`\[\]#]/.test(text)); |
||||
} |
||||
|
||||
async createNip22Event({ content, root, parent }) { |
||||
// Get user's public key
|
||||
const pubkey = await window.nostr.getPublicKey(); |
||||
const created_at = Math.floor(Date.now() / 1000); |
||||
// Build tags according to NIP-22
|
||||
const tags = []; |
||||
// Root tags (uppercase)
|
||||
if (root.tag && root.value) { |
||||
const rootTag = [root.tag.toUpperCase(), root.value]; |
||||
if (root.relay) rootTag.push(root.relay); |
||||
if (root.pubkey) rootTag.push(root.pubkey); |
||||
tags.push(rootTag); |
||||
} |
||||
if (root.kind) tags.push(['K', String(root.kind)]); |
||||
if (root.pubkey) tags.push(['P', root.pubkey, root.relay || '']); |
||||
// Parent tags (lowercase)
|
||||
if (parent.tag && parent.value) { |
||||
const parentTag = [parent.tag.toLowerCase(), parent.value]; |
||||
if (parent.relay) parentTag.push(parent.relay); |
||||
if (parent.pubkey) parentTag.push(parent.pubkey); |
||||
tags.push(parentTag); |
||||
} |
||||
if (parent.kind) tags.push(['k', String(parent.kind)]); |
||||
if (parent.pubkey) tags.push(['p', parent.pubkey, parent.relay || '']); |
||||
// NIP-22 event
|
||||
return { |
||||
kind: 1111, |
||||
created_at, |
||||
tags, |
||||
content, |
||||
pubkey |
||||
}; |
||||
} |
||||
|
||||
async sendToBackend(signedEvent, formData) { |
||||
const response = await fetch(this.publishUrlValue, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
'X-Requested-With': 'XMLHttpRequest', |
||||
'X-CSRF-TOKEN': this.csrfTokenValue |
||||
}, |
||||
body: JSON.stringify({ |
||||
event: signedEvent, |
||||
formData: formData |
||||
}) |
||||
}); |
||||
if (!response.ok) { |
||||
const errorData = await response.json().catch(() => ({})); |
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); |
||||
} |
||||
return await response.json(); |
||||
} |
||||
|
||||
showStatus(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||
} |
||||
} |
||||
showSuccess(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||
} |
||||
} |
||||
showError(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
<?php |
||||
namespace App\Controller; |
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\Routing\Annotation\Route; |
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; |
||||
|
||||
class CommentController extends AbstractController |
||||
{ |
||||
#[Route('/comments/publish', name: 'comment_publish', methods: ['POST'])] |
||||
public function publish(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response |
||||
{ |
||||
// CSRF check |
||||
$csrfToken = $request->headers->get('X-CSRF-TOKEN'); |
||||
if (!$csrfToken || !$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('comment_publish', $csrfToken))) { |
||||
return new JsonResponse(['message' => 'Invalid CSRF token'], 403); |
||||
} |
||||
|
||||
$data = json_decode($request->getContent(), true); |
||||
if (!$data || !isset($data['event'])) { |
||||
return new JsonResponse(['message' => 'Invalid request'], 400); |
||||
} |
||||
|
||||
// Here you would validate and process the NIP-22 event |
||||
// For now, just return success for integration testing |
||||
return new JsonResponse(['status' => 'ok']); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
<?php |
||||
namespace App\Twig\Components\Organisms; |
||||
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
class CommentForm |
||||
{ |
||||
public string $publish_url; |
||||
public string $csrf_token; |
||||
public array $root_context = []; |
||||
public array $parent_context = []; |
||||
public string $form_id; |
||||
|
||||
public function mount( |
||||
string $publish_url, |
||||
string $csrf_token, |
||||
array $root_context, |
||||
array $parent_context, |
||||
?string $form_id = null |
||||
): void { |
||||
$this->publish_url = $publish_url; |
||||
$this->csrf_token = $csrf_token; |
||||
$this->root_context = $root_context; |
||||
$this->parent_context = $parent_context; |
||||
$this->form_id = $form_id ?? uniqid('nip22_comment_'); |
||||
} |
||||
} |
||||
|
||||
|
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
<form data-controller="nostr-comment" |
||||
data-nostr-comment-publish-url-value="{{ publish_url }}" |
||||
data-nostr-comment-csrf-token-value="{{ csrf_token }}" |
||||
data-nostr-comment-root="{{ root_context|json_encode|e('html_attr') }}" |
||||
data-nostr-comment-parent="{{ parent_context|json_encode|e('html_attr') }}" |
||||
data-action="submit->nostr-comment#publish" |
||||
class="nip22-comment-form"> |
||||
<div data-nostr-comment-target="status"></div> |
||||
<div class="mb-2"> |
||||
<label for="comment_content_{{ form_id }}" class="form-label hidden">Comment</label> |
||||
<textarea id="comment_content_{{ form_id }}" name="comment[content]" class="form-control" rows="3" required placeholder="Write your comment"></textarea> |
||||
</div> |
||||
<input type="hidden" name="comment[root]" value='{{ root_context|json_encode|e('html_attr') }}'> |
||||
<input type="hidden" name="comment[parent]" value='{{ parent_context|json_encode|e('html_attr') }}'> |
||||
<div class="actions"> |
||||
<button type="submit" data-nostr-comment-target="publishButton" class="btn btn-primary mt-2">Publish Comment</button> |
||||
</div> |
||||
</form> |
||||
Loading…
Reference in new issue