13 changed files with 905 additions and 8 deletions
@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
/** |
||||
* Builds a NIP-22 kind-1111 event (blurb + body), signs with NIP-07, POSTs to /comment/publish. |
||||
*/ |
||||
export default class extends Controller { |
||||
static targets = ['hint']; |
||||
|
||||
static values = { |
||||
publishUrl: String, |
||||
csrf: String, |
||||
expectedCoordinate: String, |
||||
articleEventId: String, |
||||
fragmentUrl: String, |
||||
refreshAfter: { type: Boolean, default: true }, |
||||
blurbLabel: String, |
||||
expectedTags: Array, |
||||
parentKind: Number, |
||||
parentId: String, |
||||
authorPubkey: String, |
||||
}; |
||||
|
||||
connect() { |
||||
this._tags = this.expectedTagsValue; |
||||
if (!Array.isArray(this._tags)) { |
||||
const raw = this.element.getAttribute('data-comment-reply-expected-tags-value'); |
||||
try { |
||||
this._tags = raw ? JSON.parse(raw) : []; |
||||
} catch { |
||||
this._tags = []; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param {Event} ev |
||||
*/ |
||||
async publish(ev) { |
||||
ev.preventDefault(); |
||||
if (!this.hasNip07()) { |
||||
this.setHint('Install a Nostr extension (NIP-07) to sign comments.'); |
||||
return; |
||||
} |
||||
const ta = this.element.querySelector('textarea[name="body"]'); |
||||
const text = (ta?.value ?? '').trim(); |
||||
if (!text) { |
||||
this.setHint('Write something first.'); |
||||
return; |
||||
} |
||||
if (this._tags.length === 0) { |
||||
this.setHint('Missing NIP-22 tag template.'); |
||||
return; |
||||
} |
||||
this.setHint('Preparing event…'); |
||||
const { nip19 } = await import('nostr-tools'); |
||||
const link = this.buildParentBech32(nip19); |
||||
const blurb = `> Replying to **${this.blurbLabelValue}** — [view parent](nostr:${link})\n\n`; |
||||
const unsigned = { |
||||
kind: 1111, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: this._tags, |
||||
content: blurb + text, |
||||
}; |
||||
let signed; |
||||
try { |
||||
signed = await window.nostr.signEvent(unsigned); |
||||
} catch (err) { |
||||
this.setHint(`Signing failed: ${err instanceof Error ? err.message : String(err)}`); |
||||
return; |
||||
} |
||||
this.setHint('Publishing…'); |
||||
const payload = { |
||||
event: signed, |
||||
expected_coordinate: this.expectedCoordinateValue, |
||||
parent_kind: parseInt(String(this.parentKindValue), 10), |
||||
parent_id: this.parentIdValue, |
||||
article_event_id: this.articleEventIdValue || null, |
||||
csrf: this.csrfValue, |
||||
}; |
||||
let res; |
||||
try { |
||||
res = await fetch(this.publishUrlValue, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
Accept: 'application/json', |
||||
'X-CSRF-TOKEN': this.csrfValue, |
||||
}, |
||||
credentials: 'same-origin', |
||||
body: JSON.stringify(payload), |
||||
}); |
||||
} catch (err) { |
||||
this.setHint(`Network error: ${err instanceof Error ? err.message : String(err)}`); |
||||
return; |
||||
} |
||||
const data = await res.json().catch(() => ({})); |
||||
if (!res.ok) { |
||||
this.setHint(data.error || `HTTP ${res.status}`); |
||||
return; |
||||
} |
||||
this.setHint('Published. It may take a short time to show on all relays.'); |
||||
if (ta) { |
||||
ta.value = ''; |
||||
} |
||||
if (this.refreshAfterValue && this.fragmentUrlValue) { |
||||
this.refreshThread(); |
||||
} |
||||
} |
||||
|
||||
hasNip07() { |
||||
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function'; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('nostr-tools').nip19} nip19 |
||||
*/ |
||||
buildParentBech32(nip19) { |
||||
const allZero = /^0{64}$/.test(this.parentIdValue); |
||||
const parts = (this.expectedCoordinateValue || '').split(':'); |
||||
const k = parts[0] ? parseInt(parts[0], 10) : 30023; |
||||
const pub = parts[1] || this.authorPubkeyValue; |
||||
const d = parts[2] || ''; |
||||
if (allZero && d !== '') { |
||||
return nip19.naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] }); |
||||
} |
||||
return nip19.neventEncode({ |
||||
id: this.parentIdValue, |
||||
kind: this.parentKindValue, |
||||
pubkey: this.authorPubkeyValue, |
||||
relays: [], |
||||
}); |
||||
} |
||||
|
||||
refreshThread() { |
||||
const el = document.querySelector('[data-article-comments-url-value]'); |
||||
const u = el?.getAttribute('data-article-comments-url-value'); |
||||
const container = document.querySelector('[data-article-comments-target="container"]'); |
||||
if (!u || !container) { |
||||
window.location.reload(); |
||||
return; |
||||
} |
||||
void fetch(u, { headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } }) |
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) |
||||
.then((html) => { |
||||
container.innerHTML = html; |
||||
}) |
||||
.catch(() => { |
||||
window.location.reload(); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* @param {string} msg |
||||
*/ |
||||
setHint(msg) { |
||||
if (this.hasHintTarget) { |
||||
this.hintTarget.textContent = msg; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Entity\User; |
||||
use App\Service\CommentReplyService; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Symfony\Component\Security\Http\Attribute\IsGranted; |
||||
|
||||
final class CommentReplyController extends AbstractController |
||||
{ |
||||
/** |
||||
* Accepts a NIP-07–signed kind-1111 event (JSON) and publishes it to configured article relays. |
||||
* |
||||
* @see \App\Service\CommentReplyService |
||||
*/ |
||||
#[Route('/comment/publish', name: 'comment_reply_publish', methods: ['POST'])] |
||||
#[IsGranted('ROLE_USER')] |
||||
public function publish(Request $request, CommentReplyService $commentReply): JsonResponse |
||||
{ |
||||
$raw = $request->getContent(); |
||||
if ($raw === '') { |
||||
return $this->json(['ok' => false, 'error' => 'Empty body'], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
try { |
||||
/** @var array<string, mixed> $data */ |
||||
$data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); |
||||
} catch (\JsonException) { |
||||
return $this->json(['ok' => false, 'error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
|
||||
$token = $data['csrf'] ?? $request->headers->get('X-CSRF-TOKEN') ?? ''; |
||||
if (!\is_string($token) || !$this->isCsrfTokenValid('comment_reply', $token)) { |
||||
return $this->json(['ok' => false, 'error' => 'Invalid CSRF token'], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
|
||||
$user = $this->getUser(); |
||||
if (!$user instanceof User) { |
||||
return $this->json(['ok' => false, 'error' => 'Not logged in'], Response::HTTP_UNAUTHORIZED); |
||||
} |
||||
|
||||
$out = $commentReply->publishFromRequestPayload($user, $data); |
||||
if ($out['ok'] === true) { |
||||
return $this->json(['ok' => true, 'id' => $out['id']]); |
||||
} |
||||
|
||||
/** @var array{ok: false, error: string, code: int} $out */ |
||||
return $this->json(['ok' => false, 'error' => $out['error']], $out['code']); |
||||
} |
||||
} |
||||
@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Nostr; |
||||
|
||||
|
||||
/** |
||||
* Kind 1111 (NIP-22) tag rows for article threads — aligned with Jumble's |
||||
* createCommentDraftEvent (a/A + e root + e parent for nested replies). |
||||
*/ |
||||
final class Nip22CommentTags |
||||
{ |
||||
/** |
||||
* Top-level comment under a kind 30023 (or 30024) article address. |
||||
* |
||||
* @return list<list<string>> |
||||
*/ |
||||
public static function forReplyToArticle(string $coordinate, string $articleAuthorPubkeyHex): array |
||||
{ |
||||
$parts = explode(':', $coordinate, 2); |
||||
$k = \count($parts) >= 1 && ctype_digit((string) $parts[0]) ? (string) (int) $parts[0] : '30023'; |
||||
|
||||
return [ |
||||
['A', $coordinate, ''], |
||||
['P', $articleAuthorPubkeyHex], |
||||
['K', $k], |
||||
['a', $coordinate, ''], |
||||
['k', $k], |
||||
['p', $articleAuthorPubkeyHex], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
*_reply under another kind-1111 comment. Root A/E/P/K (and i/I) are taken from the parent |
||||
* event's tags; the final e/k/p point at the immediate parent comment. |
||||
* |
||||
* @param list<array<int, string|int|float>>|array<array> $rawTags |
||||
* @return list<list<string>> |
||||
*/ |
||||
public static function forReplyToComment( |
||||
string $parentCommentIdHex, |
||||
string $parentCommentAuthorPubkeyHex, |
||||
int $parentCommentKind, |
||||
array $rawTags |
||||
): array { |
||||
if ($parentCommentKind !== 1111) { |
||||
throw new \InvalidArgumentException('Parent must be NIP-22 kind 1111'); |
||||
} |
||||
if (self::isInvalidHexId($parentCommentIdHex) || self::isInvalidHexPubkey($parentCommentAuthorPubkeyHex)) { |
||||
throw new \InvalidArgumentException('Invalid parent id or pubkey'); |
||||
} |
||||
|
||||
$A = self::firstTag($rawTags, 'A', 'a'); |
||||
$E = self::firstTag($rawTags, 'E', 'e'); |
||||
$P = self::firstTag($rawTags, 'P', 'p'); |
||||
$K = self::firstTag($rawTags, 'K', 'k'); |
||||
$Iu = self::firstTag($rawTags, 'I', 'i'); |
||||
|
||||
$out = []; |
||||
if ($A !== null) { |
||||
$out[] = $A; |
||||
} |
||||
if ($E !== null) { |
||||
$out[] = self::ensureTagName($E, 'E'); |
||||
} |
||||
if ($P !== null) { |
||||
$out[] = self::ensureTagName($P, 'P'); |
||||
} |
||||
if ($K !== null) { |
||||
$out[] = self::ensureTagName($K, 'K'); |
||||
} |
||||
if ($Iu !== null) { |
||||
$out[] = self::ensureTagName($Iu, 'I'); |
||||
$out[] = self::ensureTagName($Iu, 'i'); |
||||
} |
||||
|
||||
$out[] = ['e', $parentCommentIdHex, '', $parentCommentAuthorPubkeyHex]; |
||||
$out[] = ['k', (string) $parentCommentKind]; |
||||
$out[] = ['p', $parentCommentAuthorPubkeyHex]; |
||||
|
||||
return $out; |
||||
} |
||||
|
||||
/** |
||||
* @param list<array<array-key, mixed>> $rawTags |
||||
* @return list<string>|null |
||||
*/ |
||||
private static function firstTag(array $rawTags, string $upper, string $lower): ?array |
||||
{ |
||||
foreach ([$upper, $lower] as $n) { |
||||
foreach ($rawTags as $row) { |
||||
if (!\is_array($row) || ($row[0] ?? null) === null) { |
||||
continue; |
||||
} |
||||
if ((string) $row[0] === $n) { |
||||
$norm = array_map( |
||||
static fn (mixed $c): string => \is_string($c) || \is_int($c) || \is_float($c) ? (string) $c : '', |
||||
$row |
||||
); |
||||
if ($n === $lower) { |
||||
$norm[0] = $lower; |
||||
} |
||||
if ($n === $upper) { |
||||
$norm[0] = $upper; |
||||
} |
||||
|
||||
return $norm; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $tag |
||||
* @return list<string> |
||||
*/ |
||||
private static function ensureTagName(array $tag, string $name): array |
||||
{ |
||||
$out = $tag; |
||||
$out[0] = $name; |
||||
|
||||
return $out; |
||||
} |
||||
|
||||
private static function isInvalidHexId(string $h): bool |
||||
{ |
||||
return 64 !== \strlen($h) || !ctype_xdigit($h); |
||||
} |
||||
|
||||
private static function isInvalidHexPubkey(string $h): bool |
||||
{ |
||||
return 64 !== \strlen($h) || !ctype_xdigit($h); |
||||
} |
||||
} |
||||
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\User; |
||||
use App\Enum\KindsEnum; |
||||
use nostriphant\NIP19\Bech32; |
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Event\Event as NostrWireEvent; |
||||
use swentel\nostr\Key\Key; |
||||
|
||||
/** |
||||
* Validates NIP-22 kind-1111 comment events from logged-in users and publishes to article relays. |
||||
*/ |
||||
final readonly class CommentReplyService |
||||
{ |
||||
private const STALE_EVENT_MAX_AGE_SEC = 600; |
||||
|
||||
public function __construct( |
||||
private NostrClient $nostrClient, |
||||
private LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @param array<string, mixed> $payload Decoded JSON body |
||||
* |
||||
* @return array{ok: true, id: string, relays: array<string, mixed>}|array{ok: false, error: string, code: int} |
||||
*/ |
||||
public function publishFromRequestPayload(User $user, array $payload): array |
||||
{ |
||||
$raw = $payload['event'] ?? null; |
||||
if (!\is_array($raw)) { |
||||
return ['ok' => false, 'error' => 'Missing event', 'code' => 400]; |
||||
} |
||||
$expectedCoordinate = isset($payload['expected_coordinate']) && \is_string($payload['expected_coordinate']) |
||||
? $payload['expected_coordinate'] |
||||
: ''; |
||||
if ($expectedCoordinate === '' || 3 !== \count(explode(':', $expectedCoordinate, 3))) { |
||||
return ['ok' => false, 'error' => 'Invalid expected_coordinate', 'code' => 400]; |
||||
} |
||||
|
||||
$parentKind = $payload['parent_kind'] ?? null; |
||||
$parentId = isset($payload['parent_id']) && \is_string($payload['parent_id']) ? $payload['parent_id'] : ''; |
||||
if (!\is_int($parentKind) && !\is_string($parentKind)) { |
||||
return ['ok' => false, 'error' => 'Invalid parent_kind', 'code' => 400]; |
||||
} |
||||
$parentKind = (int) $parentKind; |
||||
if ($parentId === '' || 64 !== \strlen($parentId) || !ctype_xdigit($parentId)) { |
||||
return ['ok' => false, 'error' => 'Invalid parent_id', 'code' => 400]; |
||||
} |
||||
|
||||
if (isset($payload['article_event_id']) && \is_string($payload['article_event_id']) && $payload['article_event_id'] !== '') { |
||||
$g = $payload['article_event_id']; |
||||
if (64 !== \strlen($g) || !ctype_xdigit($g)) { |
||||
return ['ok' => false, 'error' => 'Invalid article_event_id', 'code' => 400]; |
||||
} |
||||
} |
||||
|
||||
$wire = NostrWireEvent::fromVerified(\json_encode($raw, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); |
||||
if ($wire === null) { |
||||
return ['ok' => false, 'error' => 'Invalid or unverifiable event', 'code' => 400]; |
||||
} |
||||
|
||||
if ($wire->getKind() !== KindsEnum::COMMENTS->value) { |
||||
return ['ok' => false, 'error' => 'Event must be kind 1111', 'code' => 400]; |
||||
} |
||||
|
||||
$now = time(); |
||||
if ($now - $wire->getCreatedAt() > self::STALE_EVENT_MAX_AGE_SEC || $wire->getCreatedAt() > $now + 60) { |
||||
return ['ok' => false, 'error' => 'Event created_at out of range', 'code' => 400]; |
||||
} |
||||
|
||||
$key = new Key(); |
||||
$userHex = $key->convertToHex($user->getNpub() ?? ''); |
||||
if ($userHex === '' || !hash_equals($userHex, $wire->getPublicKey())) { |
||||
return ['ok' => false, 'error' => 'Pubkey does not match logged-in user', 'code' => 403]; |
||||
} |
||||
|
||||
if (!$this->tagsReferenceCoordinate($wire->getTags(), $expectedCoordinate)) { |
||||
return ['ok' => false, 'error' => 'Tags must include a/A for this article', 'code' => 400]; |
||||
} |
||||
|
||||
if (!$this->contentBlurbReferencesParent( |
||||
$wire->getContent(), |
||||
$expectedCoordinate, |
||||
$parentKind, |
||||
$parentId |
||||
)) { |
||||
return ['ok' => false, 'error' => 'Reply must start with a quote line (>) linking the parent via nostr:nevent1 / naddr1 (reply blurb)', 'code' => 400]; |
||||
} |
||||
|
||||
$relays = $this->nostrClient->getArticleWriteRelayUrls(); |
||||
$result = $this->nostrClient->publishEvent($wire, $relays); |
||||
$this->logger->info('comment_reply.published', [ |
||||
'id' => $wire->getId(), |
||||
'relays' => \array_keys($result), |
||||
]); |
||||
|
||||
return ['ok' => true, 'id' => $wire->getId(), 'relays' => $result]; |
||||
} |
||||
|
||||
/** |
||||
* @param array<int, mixed> $tags |
||||
*/ |
||||
private function tagsReferenceCoordinate(array $tags, string $coordinate): bool |
||||
{ |
||||
foreach ($tags as $row) { |
||||
if (!\is_array($row) || ($row[0] ?? null) === null) { |
||||
continue; |
||||
} |
||||
$n = (string) $row[0]; |
||||
if ($n === 'a' || $n === 'A') { |
||||
if (($row[1] ?? '') === $coordinate) { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
private function contentBlurbReferencesParent( |
||||
string $content, |
||||
string $articleCoordinate, |
||||
int $parentKind, |
||||
string $parentIdHex |
||||
): bool { |
||||
$head = \strlen($content) > 800 ? substr($content, 0, 800) : $content; |
||||
if (!str_contains($head, "\n\n")) { |
||||
return false; |
||||
} |
||||
[$blurb] = explode("\n\n", $head, 2); |
||||
$blurb = trim($blurb); |
||||
if ($blurb === '' || !str_starts_with($blurb, '>')) { |
||||
return false; |
||||
} |
||||
if (!preg_match('/nostr:(nevent1[0-9a-z]+|naddr1[0-9a-z]+|note1[0-9a-z]+)/i', $blurb, $m)) { |
||||
return false; |
||||
} |
||||
try { |
||||
$decoded = new Bech32($m[1]); |
||||
} catch (\Throwable) { |
||||
return false; |
||||
} |
||||
if ($decoded->type === 'nevent') { |
||||
$id = $decoded->data->id ?? null; |
||||
|
||||
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); |
||||
} |
||||
if ($decoded->type === 'note') { |
||||
$id = $decoded->data->identifier ?? null; |
||||
|
||||
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); |
||||
} |
||||
if ($decoded->type === 'naddr') { |
||||
$d = $decoded->data; |
||||
$coord = $d->kind.':'.$d->pubkey.':'.$d->identifier; |
||||
if (!\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { |
||||
return false; |
||||
} |
||||
if (!hash_equals($articleCoordinate, $coord)) { |
||||
return false; |
||||
} |
||||
$zero = str_repeat('0', 64); |
||||
|
||||
return hash_equals($parentIdHex, $zero); |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue