13 changed files with 905 additions and 8 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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