7 changed files with 283 additions and 12 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<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