19 changed files with 1655 additions and 32 deletions
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
/** |
||||
* Shows or hides the "Edit magazine" footer link when auth changes without a full page reload. |
||||
*/ |
||||
export default class extends Controller { |
||||
static values = { |
||||
publisherNpub: String, |
||||
}; |
||||
|
||||
connect() { |
||||
this.boundOnAuth ??= this.onAuthChanged.bind(this); |
||||
window.removeEventListener('unfold:auth-changed', this.boundOnAuth); |
||||
window.addEventListener('unfold:auth-changed', this.boundOnAuth); |
||||
} |
||||
|
||||
disconnect() { |
||||
window.removeEventListener('unfold:auth-changed', this.boundOnAuth); |
||||
} |
||||
|
||||
onAuthChanged(event) { |
||||
const d = event.detail; |
||||
if (!d) { |
||||
return; |
||||
} |
||||
if (d.loggedIn === false) { |
||||
this.element.hidden = true; |
||||
return; |
||||
} |
||||
if (d.loggedIn && d.npub === this.publisherNpubValue) { |
||||
this.element.hidden = false; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,476 @@
@@ -0,0 +1,476 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
const KIND_PUBLICATION_INDEX = 30040; |
||||
const KIND_LONGFORM = 30023; |
||||
const KIND_LONGFORM_DRAFT = 30024; |
||||
|
||||
/** |
||||
* Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch. |
||||
* Only signs nodes that differ from the page load snapshot, plus any other index required for |
||||
* graph closure (root + every nested kind-30040 `a` reachable from those events). |
||||
*/ |
||||
export default class extends Controller { |
||||
static targets = ['status', 'node']; |
||||
|
||||
static values = { |
||||
publishUrl: String, |
||||
csrf: String, |
||||
ownerHex: String, |
||||
rootDTag: String, |
||||
}; |
||||
|
||||
connect() { |
||||
this.nodeBaseline = new WeakMap(); |
||||
this.captureBaselines(); |
||||
} |
||||
|
||||
captureBaselines() { |
||||
for (const el of this.nodeTargets) { |
||||
this.nodeBaseline.set(el, snapshotFromElement(el)); |
||||
} |
||||
} |
||||
|
||||
publish() { |
||||
void this._publish(); |
||||
} |
||||
|
||||
async _publish() { |
||||
this.setStatus(''); |
||||
if (!this.hasNip07()) { |
||||
this.setStatus('Install a Nostr extension (NIP-07) to sign index events.'); |
||||
return; |
||||
} |
||||
const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); |
||||
if (ownerHex.length !== 64) { |
||||
this.setStatus('Missing owner pubkey.'); |
||||
return; |
||||
} |
||||
const rootD = (this.rootDTagValue || '').trim(); |
||||
if (rootD === '') { |
||||
this.setStatus('Missing root index #d.'); |
||||
return; |
||||
} |
||||
|
||||
const nodes = this.nodeTargets; |
||||
if (nodes.length === 0) { |
||||
this.setStatus('Nothing to publish.'); |
||||
return; |
||||
} |
||||
|
||||
const dirty = nodes.filter((el) => this.isNodeDirty(el)); |
||||
if (dirty.length === 0) { |
||||
this.setStatus('No changes to publish.'); |
||||
return; |
||||
} |
||||
|
||||
let publishSet; |
||||
try { |
||||
publishSet = await this.expandPublishSet(rootD, dirty, ownerHex); |
||||
} catch (err) { |
||||
this.setStatus(err instanceof Error ? err.message : String(err)); |
||||
return; |
||||
} |
||||
|
||||
const ordered = nodes.filter((el) => publishSet.has(readDTag(el))); |
||||
if (ordered.length === 0) { |
||||
this.setStatus('Nothing to publish.'); |
||||
return; |
||||
} |
||||
|
||||
const baseTime = Math.floor(Date.now() / 1000); |
||||
const signedEvents = []; |
||||
|
||||
this.setStatus( |
||||
ordered.length === dirty.length |
||||
? 'Signing…' |
||||
: `Signing ${ordered.length} index event(s) (${dirty.length} edited, ${ordered.length - dirty.length} required for nested links)…`, |
||||
); |
||||
|
||||
for (let i = 0; i < ordered.length; i++) { |
||||
const el = ordered[i]; |
||||
const dTag = readDTag(el); |
||||
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; |
||||
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; |
||||
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; |
||||
const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? ''; |
||||
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; |
||||
|
||||
let preserved; |
||||
try { |
||||
preserved = JSON.parse(preservedRaw); |
||||
} catch { |
||||
this.setStatus(`Invalid preserved-tags JSON for #d ${dTag}.`); |
||||
return; |
||||
} |
||||
if (!Array.isArray(preserved)) { |
||||
this.setStatus(`Preserved tags must be a JSON array for #d ${dTag}.`); |
||||
return; |
||||
} |
||||
|
||||
let tags; |
||||
try { |
||||
tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); |
||||
} catch (err) { |
||||
this.setStatus(err instanceof Error ? err.message : String(err)); |
||||
return; |
||||
} |
||||
|
||||
const unsigned = { |
||||
kind: KIND_PUBLICATION_INDEX, |
||||
created_at: baseTime + i, |
||||
tags, |
||||
content: content ?? '', |
||||
}; |
||||
|
||||
let signed; |
||||
try { |
||||
signed = await window.nostr.signEvent(unsigned); |
||||
} catch (err) { |
||||
this.setStatus(`Signing failed (#d ${dTag}): ${err instanceof Error ? err.message : String(err)}`); |
||||
return; |
||||
} |
||||
signedEvents.push(signed); |
||||
} |
||||
|
||||
this.setStatus('Publishing…'); |
||||
let res; |
||||
try { |
||||
res = await fetch(this.publishUrlValue, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
Accept: 'application/json', |
||||
}, |
||||
credentials: 'same-origin', |
||||
body: JSON.stringify({ csrf: this.csrfValue, events: signedEvents }), |
||||
}); |
||||
} catch (err) { |
||||
this.setStatus(`Network error: ${err instanceof Error ? err.message : String(err)}`); |
||||
return; |
||||
} |
||||
|
||||
const data = await res.json().catch(() => ({})); |
||||
if (!res.ok) { |
||||
this.setStatus(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`); |
||||
return; |
||||
} |
||||
const n = Number(data.published); |
||||
for (const el of ordered) { |
||||
this.nodeBaseline.set(el, snapshotFromElement(el)); |
||||
} |
||||
this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); |
||||
} |
||||
|
||||
isNodeDirty(el) { |
||||
const cur = snapshotFromElement(el); |
||||
const base = this.nodeBaseline.get(el); |
||||
if (!base) { |
||||
return true; |
||||
} |
||||
return ( |
||||
cur.title !== base.title || |
||||
cur.summary !== base.summary || |
||||
cur.content !== base.content || |
||||
cur.aText !== base.aText || |
||||
cur.preservedRaw !== base.preservedRaw |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Minimal set of #d tags that must appear in the POST body: dirty nodes, the root, and every |
||||
* kind-30040 child referenced (transitively) from those events' `a` tags — matches server |
||||
* {@see MagazineHierarchyPublishService::validateGraphClosure}. |
||||
* |
||||
* @param {HTMLElement} dirtyFieldsets |
||||
* @returns {Promise<Set<string>>} |
||||
*/ |
||||
async expandPublishSet(rootD, dirtyFieldsets, ownerHex) { |
||||
/** @type {Map<string, HTMLElement>} */ |
||||
const dToEl = new Map(); |
||||
for (const el of this.nodeTargets) { |
||||
const d = readDTag(el); |
||||
if (d) { |
||||
dToEl.set(d, el); |
||||
} |
||||
} |
||||
|
||||
const W = new Set(); |
||||
for (const el of dirtyFieldsets) { |
||||
const d = readDTag(el); |
||||
if (d) { |
||||
W.add(d); |
||||
} |
||||
} |
||||
W.add(rootD); |
||||
|
||||
let growing = true; |
||||
let guard = 0; |
||||
while (growing && guard < 256) { |
||||
guard += 1; |
||||
growing = false; |
||||
const snapshot = [...W]; |
||||
for (const d of snapshot) { |
||||
const el = dToEl.get(d); |
||||
if (!el) { |
||||
continue; |
||||
} |
||||
const { dTag, title, summary, content, aText, preserved } = readFieldsForBuild(el); |
||||
const tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); |
||||
for (const childD of ownedNested30040DsFromTags(tags, ownerHex)) { |
||||
if (!W.has(childD)) { |
||||
W.add(childD); |
||||
growing = true; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return W; |
||||
} |
||||
|
||||
/** |
||||
* @param {string} dTag |
||||
* @param {unknown[]} preserved |
||||
* @param {string} title |
||||
* @param {string} summary |
||||
* @param {string} aText |
||||
* @param {string} ownerHex |
||||
* @returns {Promise<string[][]>} |
||||
*/ |
||||
async buildTags(dTag, preserved, title, summary, aText, ownerHex) { |
||||
if (!dTag) { |
||||
throw new Error('Every node must have a #d value.'); |
||||
} |
||||
/** @type {string[][]} */ |
||||
const tags = [['d', dTag]]; |
||||
for (const row of preserved) { |
||||
if (!Array.isArray(row) || row.length === 0) { |
||||
continue; |
||||
} |
||||
const norm = row.map((c) => String(c)); |
||||
if (norm[0].toLowerCase() === 'd') { |
||||
continue; |
||||
} |
||||
tags.push(norm); |
||||
} |
||||
tags.push(['title', title]); |
||||
tags.push(['summary', summary]); |
||||
|
||||
const lines = aText |
||||
.split('\n') |
||||
.map((l) => l.trim()) |
||||
.filter((l) => l.length > 0); |
||||
for (const line of lines) { |
||||
const canonical = await normalizeAddressableCoordinate(line); |
||||
this.assertValidCoordinate(canonical, ownerHex); |
||||
tags.push(['a', canonical]); |
||||
} |
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* @param {string} coord |
||||
* @param {string} ownerHex |
||||
*/ |
||||
assertValidCoordinate(coord, ownerHex) { |
||||
const parts = splitThree(coord); |
||||
if (!parts) { |
||||
throw new Error(`Invalid coordinate (use kind:pubkey:identifier): ${coord}`); |
||||
} |
||||
const kind = parseInt(parts.kind, 10); |
||||
const pk = parts.pubkey.toLowerCase(); |
||||
const id = parts.identifier.trim(); |
||||
if (id.length === 0 || pk.length !== 64 || !/^[0-9a-f]+$/.test(pk)) { |
||||
throw new Error(`Invalid pubkey or identifier in: ${coord}`); |
||||
} |
||||
if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) { |
||||
throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`); |
||||
} |
||||
if (kind !== KIND_PUBLICATION_INDEX && kind !== KIND_LONGFORM && kind !== KIND_LONGFORM_DRAFT) { |
||||
throw new Error(`Only kinds 30040, 30023, 30024 allowed in a tag: ${coord}`); |
||||
} |
||||
} |
||||
|
||||
setStatus(msg) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.textContent = msg; |
||||
} |
||||
} |
||||
|
||||
hasNip07() { |
||||
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function'; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param {string[][]} tags |
||||
* @param {string} ownerHex |
||||
* @returns {Set<string>} |
||||
*/ |
||||
function ownedNested30040DsFromTags(tags, ownerHex) { |
||||
const out = new Set(); |
||||
const oh = ownerHex.toLowerCase(); |
||||
for (const row of tags) { |
||||
if (row.length < 2 || String(row[0]).toLowerCase() !== 'a') { |
||||
continue; |
||||
} |
||||
const coord = String(row[1]).trim(); |
||||
const parts = splitThree(coord); |
||||
if (!parts) { |
||||
continue; |
||||
} |
||||
const kind = parseInt(parts.kind, 10); |
||||
if (kind !== KIND_PUBLICATION_INDEX) { |
||||
continue; |
||||
} |
||||
const pk = parts.pubkey.toLowerCase(); |
||||
if (pk !== oh) { |
||||
continue; |
||||
} |
||||
const id = parts.identifier.trim(); |
||||
if (id !== '') { |
||||
out.add(id); |
||||
} |
||||
} |
||||
return out; |
||||
} |
||||
|
||||
/** |
||||
* @param {HTMLElement} el |
||||
*/ |
||||
function readDTag(el) { |
||||
return (el.querySelector('[data-magazine-hierarchy-editor-target="dTag"]')?.value || '').trim(); |
||||
} |
||||
|
||||
/** |
||||
* @param {HTMLElement} el |
||||
*/ |
||||
function snapshotFromElement(el) { |
||||
return { |
||||
title: el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '', |
||||
summary: el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '', |
||||
content: el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '', |
||||
aText: el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? '', |
||||
preservedRaw: el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]', |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* @param {HTMLElement} el |
||||
* @returns {{ dTag: string, title: string, summary: string, content: string, aText: string, preserved: unknown[] }} |
||||
*/ |
||||
function readFieldsForBuild(el) { |
||||
const dTag = readDTag(el); |
||||
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; |
||||
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; |
||||
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; |
||||
const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? ''; |
||||
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; |
||||
let preserved; |
||||
try { |
||||
preserved = JSON.parse(preservedRaw); |
||||
} catch { |
||||
throw new Error(`Invalid preserved-tags JSON for #d ${dTag}.`); |
||||
} |
||||
if (!Array.isArray(preserved)) { |
||||
throw new Error(`Preserved tags must be a JSON array for #d ${dTag}.`); |
||||
} |
||||
return { dTag, title, summary, content, aText, preserved }; |
||||
} |
||||
|
||||
/** |
||||
* @param {string} coord |
||||
* @returns {{ kind: string, pubkey: string, identifier: string } | null} |
||||
*/ |
||||
function splitThree(coord) { |
||||
const s = coord.trim(); |
||||
const i = s.indexOf(':'); |
||||
if (i < 1) { |
||||
return null; |
||||
} |
||||
const j = s.indexOf(':', i + 1); |
||||
if (j <= i) { |
||||
return null; |
||||
} |
||||
return { |
||||
kind: s.slice(0, i), |
||||
pubkey: s.slice(i + 1, j), |
||||
identifier: s.slice(j + 1), |
||||
}; |
||||
} |
||||
|
||||
/** @type {Promise<(s: string) => unknown> | null} */ |
||||
let nip19DecodeLoader = null; |
||||
|
||||
/** |
||||
* @returns {Promise<(s: string) => unknown>} |
||||
*/ |
||||
function loadNip19Decode() { |
||||
if (!nip19DecodeLoader) { |
||||
nip19DecodeLoader = import('nostr-tools/nip19').then((m) => m.decode); |
||||
} |
||||
return nip19DecodeLoader; |
||||
} |
||||
|
||||
/** |
||||
* Strips optional `nostr:` scheme and surrounding whitespace. |
||||
* @param {string} raw |
||||
*/ |
||||
function stripNostrScheme(raw) { |
||||
let s = raw.trim(); |
||||
if (/^nostr:/i.test(s)) { |
||||
s = s.slice(6).trim(); |
||||
} |
||||
return s; |
||||
} |
||||
|
||||
/** |
||||
* Accepts `kind:pubkey:identifier` or NIP-19 `naddr1…` (optional `nostr:` prefix). |
||||
* @param {string} raw |
||||
* @returns {Promise<string>} |
||||
*/ |
||||
async function normalizeAddressableCoordinate(raw) { |
||||
const s = stripNostrScheme(raw); |
||||
if (s === '') { |
||||
throw new Error('Empty `a` line.'); |
||||
} |
||||
if (/^naddr1[0-9a-z]+$/i.test(s)) { |
||||
let decode; |
||||
try { |
||||
decode = await loadNip19Decode(); |
||||
} catch (e) { |
||||
throw new Error(`Could not load NIP-19 decoder: ${e instanceof Error ? e.message : String(e)}`); |
||||
} |
||||
let decoded; |
||||
try { |
||||
decoded = decode(s); |
||||
} catch (e) { |
||||
throw new Error(`Invalid naddr: ${e instanceof Error ? e.message : String(e)}`); |
||||
} |
||||
if (decoded.type !== 'naddr' || !decoded.data || typeof decoded.data !== 'object') { |
||||
throw new Error('Decoded value is not an naddr.'); |
||||
} |
||||
const { kind, pubkey, identifier } = decoded.data; |
||||
if (typeof kind !== 'number' || !Number.isFinite(kind)) { |
||||
throw new Error('naddr is missing kind.'); |
||||
} |
||||
if (typeof pubkey !== 'string' || pubkey.length !== 64 || !/^[0-9a-fA-F]+$/.test(pubkey)) { |
||||
throw new Error('naddr is missing valid author pubkey hex.'); |
||||
} |
||||
if (typeof identifier !== 'string' || identifier.trim() === '') { |
||||
throw new Error('naddr is missing identifier (#d).'); |
||||
} |
||||
return `${kind}:${pubkey.toLowerCase()}:${identifier}`; |
||||
} |
||||
const parts = splitThree(s); |
||||
if (!parts) { |
||||
throw new Error( |
||||
`Invalid line (use kind:pubkey:identifier or naddr1…): ${raw.length > 120 ? raw.slice(0, 120) + '…' : raw}`, |
||||
); |
||||
} |
||||
const kind = parseInt(parts.kind, 10); |
||||
if (!Number.isFinite(kind)) { |
||||
throw new Error(`Invalid kind in coordinate: ${parts.kind}`); |
||||
} |
||||
return `${kind}:${parts.pubkey.toLowerCase()}:${parts.identifier}`; |
||||
} |
||||
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
.magazine-editor { |
||||
max-width: 52rem; |
||||
margin-inline: auto; |
||||
} |
||||
|
||||
.magazine-editor__intro { |
||||
margin: 0 0 1.25rem; |
||||
line-height: 1.5; |
||||
color: var(--color-text-mid, inherit); |
||||
} |
||||
|
||||
.magazine-editor__status { |
||||
min-height: 1.5rem; |
||||
margin: 0 0 1rem; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
.magazine-editor__nodes { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.magazine-editor__node { |
||||
margin: 0; |
||||
padding: 1rem 1rem 1.1rem; |
||||
border: 1px solid var(--color-border); |
||||
background: var(--color-bg-light, var(--color-bg)); |
||||
--mag-node-depth: 0; |
||||
} |
||||
|
||||
/* Tree rail for nested kind-30040 sections (depth ≥ 2 under root). */ |
||||
.magazine-editor__node--nested { |
||||
position: relative; |
||||
padding-left: calc(1rem + 0.35rem); |
||||
border-left: 2px solid var(--color-border, rgba(120, 120, 120, 0.42)); |
||||
border-radius: 0 2px 2px 0; |
||||
} |
||||
|
||||
.magazine-editor__node--nested::before { |
||||
content: ""; |
||||
position: absolute; |
||||
left: -2px; |
||||
top: 0.85rem; |
||||
width: 0.65rem; |
||||
height: 2px; |
||||
background: var(--color-border, rgba(120, 120, 120, 0.42)); |
||||
border-radius: 1px; |
||||
} |
||||
|
||||
.magazine-editor__legend { |
||||
padding: 0 0.35rem; |
||||
font-weight: 600; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
.magazine-editor__slug { |
||||
font-weight: 400; |
||||
font-size: 0.85rem; |
||||
margin-left: 0.35rem; |
||||
} |
||||
|
||||
.magazine-editor__label { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.35rem; |
||||
margin: 0 0 0.9rem; |
||||
} |
||||
|
||||
.magazine-editor__label-text { |
||||
font-size: 0.82rem; |
||||
font-weight: 600; |
||||
letter-spacing: 0.02em; |
||||
} |
||||
|
||||
.magazine-editor__input, |
||||
.magazine-editor__textarea { |
||||
width: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.magazine-editor__textarea--mono { |
||||
font-family: ui-monospace, monospace; |
||||
font-size: 0.82rem; |
||||
line-height: 1.45; |
||||
} |
||||
|
||||
.magazine-editor__actions { |
||||
margin-top: 1.25rem; |
||||
padding-top: 1rem; |
||||
border-top: 1px solid var(--color-border); |
||||
} |
||||
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Entity\User; |
||||
use App\Service\MagazineHierarchyEditorService; |
||||
use App\Service\MagazineHierarchyPublishService; |
||||
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\Csrf\CsrfToken; |
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; |
||||
use Symfony\Component\Security\Http\Attribute\IsGranted; |
||||
|
||||
final class MagazineEditorController extends AbstractController |
||||
{ |
||||
private function assertMagazineOwner(): User |
||||
{ |
||||
$user = $this->getUser(); |
||||
if (!$user instanceof User) { |
||||
throw $this->createAccessDeniedException(); |
||||
} |
||||
$configured = (string) $this->getParameter('npub'); |
||||
$npub = (string) ($user->getNpub() ?? ''); |
||||
if ($npub === '' || !hash_equals($configured, $npub)) { |
||||
throw $this->createAccessDeniedException('Only the configured magazine owner npub may use this editor.'); |
||||
} |
||||
|
||||
return $user; |
||||
} |
||||
|
||||
#[Route('/magazine/edit', name: 'magazine_edit')] |
||||
#[IsGranted('ROLE_USER')] |
||||
public function edit(MagazineHierarchyEditorService $editor, CsrfTokenManagerInterface $csrfTokenManager): Response |
||||
{ |
||||
$this->assertMagazineOwner(); |
||||
$payload = $editor->buildEditorPayload(); |
||||
|
||||
return $this->render('pages/magazine_edit.html.twig', [ |
||||
'editor_payload' => $payload, |
||||
'magazine_edit_csrf' => $csrfTokenManager->getToken('magazine_edit')->getValue(), |
||||
]); |
||||
} |
||||
|
||||
#[Route('/magazine/edit/publish', name: 'magazine_edit_publish', methods: ['POST'])] |
||||
#[IsGranted('ROLE_USER')] |
||||
public function publish( |
||||
Request $request, |
||||
MagazineHierarchyPublishService $publishService, |
||||
CsrfTokenManagerInterface $csrfTokenManager, |
||||
): JsonResponse { |
||||
$user = $this->assertMagazineOwner(); |
||||
|
||||
try { |
||||
$data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); |
||||
} catch (\JsonException) { |
||||
return new JsonResponse(['ok' => false, 'error' => 'Invalid JSON body'], 400); |
||||
} |
||||
if (!\is_array($data)) { |
||||
return new JsonResponse(['ok' => false, 'error' => 'Invalid JSON body'], 400); |
||||
} |
||||
|
||||
$token = isset($data['csrf']) && \is_string($data['csrf']) ? $data['csrf'] : ''; |
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('magazine_edit', $token))) { |
||||
return new JsonResponse(['ok' => false, 'error' => 'Invalid CSRF token'], 400); |
||||
} |
||||
|
||||
$events = $data['events'] ?? null; |
||||
if (!\is_array($events)) { |
||||
return new JsonResponse(['ok' => false, 'error' => 'Missing events array'], 400); |
||||
} |
||||
|
||||
$result = $publishService->publishOwnerMagazineBatch($user, $events); |
||||
if ($result['ok'] === true) { |
||||
return new JsonResponse($result); |
||||
} |
||||
|
||||
return new JsonResponse( |
||||
['ok' => false, 'error' => $result['error']], |
||||
min(599, max(400, $result['code'])), |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,419 @@
@@ -0,0 +1,419 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Event as PublicationEventEntity; |
||||
use App\Util\NostrEventTags; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
|
||||
/** |
||||
* Builds JSON-safe payloads for the owner-only magazine hierarchy editor (kind 30040 root + categories). |
||||
*/ |
||||
final class MagazineHierarchyEditorService |
||||
{ |
||||
public function __construct( |
||||
private readonly MagazineIndexStore $store, |
||||
private readonly MagazineContentService $magazineContent, |
||||
private readonly NostrClient $nostrClient, |
||||
private readonly NostrKeyHelper $nostrKeyHelper, |
||||
private readonly ParameterBagInterface $params, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @return array{ |
||||
* owner_hex: string, |
||||
* root_d_tag: string, |
||||
* site_name: string, |
||||
* nodes: list<array{ |
||||
* d_tag: string, |
||||
* is_root: bool, |
||||
* depth: int, |
||||
* title: string, |
||||
* summary: string, |
||||
* content: string, |
||||
* preserved_tags: list<list<string>>, |
||||
* a_coordinates: list<string> |
||||
* }> |
||||
* } |
||||
*/ |
||||
public function buildEditorPayload(): array |
||||
{ |
||||
$npub = (string) $this->params->get('npub'); |
||||
$dTag = (string) $this->params->get('d_tag'); |
||||
$siteName = (string) $this->params->get('name'); |
||||
$ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); |
||||
|
||||
$root = $this->store->getRoot($npub, $dTag); |
||||
if ($root === null) { |
||||
try { |
||||
$fetched = $this->nostrClient->getMagazineIndex($npub, $dTag); |
||||
if ($fetched !== null) { |
||||
$this->store->putRoot($npub, $dTag, $fetched); |
||||
$root = $fetched; |
||||
} |
||||
} catch (\Throwable) { |
||||
} |
||||
} |
||||
|
||||
$parentByChild = $this->buildCategoryParentByChildD($npub, $dTag, $ownerHex); |
||||
|
||||
$nodes = []; |
||||
if ($root !== null) { |
||||
$nodes[] = $this->withDepth($this->nodeFromEvent($root, $dTag, true, $ownerHex, $siteName), 0); |
||||
} else { |
||||
$nodes[] = $this->withDepth($this->emptyRootNode($dTag, $ownerHex, $siteName), 0); |
||||
} |
||||
|
||||
foreach ($this->orderedCategorySlugsForEditor($npub, $dTag, $ownerHex) as $slug) { |
||||
$slug = trim($slug); |
||||
if ($slug === '') { |
||||
continue; |
||||
} |
||||
$cat = $this->store->getCategory($slug); |
||||
if ($cat !== null) { |
||||
$nodes[] = $this->withDepth( |
||||
$this->nodeFromEvent($cat, $slug, false, $ownerHex, $siteName), |
||||
$this->depthForCategorySlug($slug, $dTag, $parentByChild), |
||||
); |
||||
} else { |
||||
$nodes[] = $this->withDepth( |
||||
$this->emptyCategoryNode($slug, $ownerHex, $siteName), |
||||
$this->depthForCategorySlug($slug, $dTag, $parentByChild), |
||||
); |
||||
} |
||||
} |
||||
|
||||
return [ |
||||
'owner_hex' => $ownerHex, |
||||
'root_d_tag' => $dTag, |
||||
'site_name' => $siteName, |
||||
'nodes' => $nodes, |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* Category #d values in depth-first pre-order: each index is immediately followed by its nested |
||||
* kind-30040 children (same order as {@code a} tags), matching the magazine tree rather than BFS. |
||||
* Appends any store slugs not linked from the cached root, then orphan refs (unchanged behaviour). |
||||
* |
||||
* @return list<string> |
||||
*/ |
||||
private function orderedCategorySlugsForEditor(string $npub, string $rootD, string $ownerHex): array |
||||
{ |
||||
$out = []; |
||||
$seen = []; |
||||
$rootEvent = $this->store->getRoot($npub, $rootD); |
||||
if ($rootEvent !== null) { |
||||
$dfs = function (string $slug) use (&$dfs, &$out, &$seen, $ownerHex, $rootD): void { |
||||
$slug = trim($slug); |
||||
if ($slug === '' || hash_equals($rootD, $slug) || isset($seen[$slug])) { |
||||
return; |
||||
} |
||||
$seen[$slug] = true; |
||||
$out[] = $slug; |
||||
$cat = $this->store->getCategory($slug); |
||||
if ($cat === null) { |
||||
return; |
||||
} |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $child) { |
||||
$dfs($child); |
||||
} |
||||
}; |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $child) { |
||||
$dfs($child); |
||||
} |
||||
} |
||||
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { |
||||
$slug = trim((string) $slug); |
||||
if ($slug === '' || isset($seen[$slug])) { |
||||
continue; |
||||
} |
||||
$seen[$slug] = true; |
||||
$out[] = $slug; |
||||
} |
||||
foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, array_keys($seen)) as $slug) { |
||||
$slug = trim((string) $slug); |
||||
if ($slug === '' || isset($seen[$slug])) { |
||||
continue; |
||||
} |
||||
$seen[$slug] = true; |
||||
$out[] = $slug; |
||||
} |
||||
|
||||
return $out; |
||||
} |
||||
|
||||
/** |
||||
* @param array{d_tag: string, is_root: bool, title: string, summary: string, content: string, preserved_tags: list<list<string>>, a_coordinates: list<string>} $node |
||||
* |
||||
* @return array{ |
||||
* d_tag: string, |
||||
* is_root: bool, |
||||
* depth: int, |
||||
* title: string, |
||||
* summary: string, |
||||
* content: string, |
||||
* preserved_tags: list<list<string>>, |
||||
* a_coordinates: list<string> |
||||
* } |
||||
*/ |
||||
private function withDepth(array $node, int $depth): array |
||||
{ |
||||
$node['depth'] = $depth; |
||||
|
||||
return $node; |
||||
} |
||||
|
||||
/** |
||||
* Maps each category #d to the parent index #d (root magazine #d for top-level categories). |
||||
* First parent wins when multiple indices reference the same child. |
||||
* |
||||
* @return array<string, string> |
||||
*/ |
||||
private function buildCategoryParentByChildD(string $npub, string $rootD, string $ownerHex): array |
||||
{ |
||||
$ownerHex = strtolower($ownerHex); |
||||
$parentByChild = []; |
||||
$rootEvent = $this->store->getRoot($npub, $rootD); |
||||
if ($rootEvent !== null) { |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $childD) { |
||||
if ($childD === '' || hash_equals($rootD, $childD)) { |
||||
continue; |
||||
} |
||||
if (!isset($parentByChild[$childD])) { |
||||
$parentByChild[$childD] = $rootD; |
||||
} |
||||
} |
||||
} |
||||
$listed = $this->magazineContent->getCategorySlugsFromStore(); |
||||
foreach ($listed as $slug) { |
||||
$slug = trim((string) $slug); |
||||
if ($slug === '') { |
||||
continue; |
||||
} |
||||
$cat = $this->store->getCategory($slug); |
||||
if ($cat === null) { |
||||
continue; |
||||
} |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { |
||||
if ($childD === '' || hash_equals($rootD, $childD)) { |
||||
continue; |
||||
} |
||||
if (!isset($parentByChild[$childD])) { |
||||
$parentByChild[$childD] = $slug; |
||||
} |
||||
} |
||||
} |
||||
foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, $listed) as $slug) { |
||||
$slug = trim((string) $slug); |
||||
if ($slug === '') { |
||||
continue; |
||||
} |
||||
$cat = $this->store->getCategory($slug); |
||||
if ($cat === null) { |
||||
continue; |
||||
} |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { |
||||
if ($childD === '' || hash_equals($rootD, $childD)) { |
||||
continue; |
||||
} |
||||
if (!isset($parentByChild[$childD])) { |
||||
$parentByChild[$childD] = $slug; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return $parentByChild; |
||||
} |
||||
|
||||
/** |
||||
* 1 = category linked from the root index; larger = deeper nested kind-30040. |
||||
*/ |
||||
private function depthForCategorySlug(string $slug, string $rootD, array $parentByChild): int |
||||
{ |
||||
if ($slug === $rootD) { |
||||
return 0; |
||||
} |
||||
if (!isset($parentByChild[$slug])) { |
||||
return 1; |
||||
} |
||||
$depth = 1; |
||||
$current = $slug; |
||||
for ($i = 0; $i < 64; ++$i) { |
||||
$parent = $parentByChild[$current] ?? null; |
||||
if ($parent === null) { |
||||
return $depth; |
||||
} |
||||
if (hash_equals($rootD, $parent)) { |
||||
return $depth; |
||||
} |
||||
$current = $parent; |
||||
++$depth; |
||||
} |
||||
|
||||
return $depth; |
||||
} |
||||
|
||||
/** |
||||
* @return list<list<string>> |
||||
*/ |
||||
private function defaultCategoryPreservedTags(string $ownerHex, string $siteName): array |
||||
{ |
||||
return [ |
||||
['type', 'magazine'], |
||||
['l', 'en, ISO-639-1'], |
||||
['reading-direction', 'left-to-right, top-to-bottom'], |
||||
['published_by', $siteName], |
||||
['p', $ownerHex], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* @return array{ |
||||
* d_tag: string, |
||||
* is_root: bool, |
||||
* title: string, |
||||
* summary: string, |
||||
* content: string, |
||||
* preserved_tags: list<list<string>>, |
||||
* a_coordinates: list<string> |
||||
* } |
||||
*/ |
||||
private function emptyRootNode(string $dTag, string $ownerHex, string $siteName): array |
||||
{ |
||||
return [ |
||||
'd_tag' => $dTag, |
||||
'is_root' => true, |
||||
'title' => '', |
||||
'summary' => '', |
||||
'content' => '', |
||||
'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), |
||||
'a_coordinates' => [], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* @return array{ |
||||
* d_tag: string, |
||||
* is_root: bool, |
||||
* title: string, |
||||
* summary: string, |
||||
* content: string, |
||||
* preserved_tags: list<list<string>>, |
||||
* a_coordinates: list<string> |
||||
* } |
||||
*/ |
||||
private function emptyCategoryNode(string $slug, string $ownerHex, string $siteName): array |
||||
{ |
||||
return [ |
||||
'd_tag' => $slug, |
||||
'is_root' => false, |
||||
'title' => $slug, |
||||
'summary' => '', |
||||
'content' => '', |
||||
'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), |
||||
'a_coordinates' => [], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* @return array{ |
||||
* d_tag: string, |
||||
* is_root: bool, |
||||
* title: string, |
||||
* summary: string, |
||||
* content: string, |
||||
* preserved_tags: list<list<string>>, |
||||
* a_coordinates: list<string> |
||||
* } |
||||
*/ |
||||
private function nodeFromEvent(PublicationEventEntity $event, string $dTag, bool $isRoot, string $ownerHex, string $siteName): array |
||||
{ |
||||
$title = ''; |
||||
$summary = ''; |
||||
$preserved = []; |
||||
$aCoords = []; |
||||
foreach ($event->getTags() as $row) { |
||||
$seq = NostrEventTags::rowToStringList($row); |
||||
if ($seq === null || $seq === []) { |
||||
continue; |
||||
} |
||||
$name = strtolower((string) $seq[0]); |
||||
if ($name === 'd') { |
||||
continue; |
||||
} |
||||
if ($name === 'title') { |
||||
$title = isset($seq[1]) ? (string) $seq[1] : ''; |
||||
|
||||
continue; |
||||
} |
||||
if ($name === 'summary') { |
||||
$summary = isset($seq[1]) ? (string) $seq[1] : ''; |
||||
|
||||
continue; |
||||
} |
||||
if ($name === 'a') { |
||||
$coord = isset($seq[1]) ? trim((string) $seq[1]) : ''; |
||||
if ($coord !== '') { |
||||
$aCoords[] = $coord; |
||||
} |
||||
|
||||
continue; |
||||
} |
||||
$preserved[] = array_map(static fn (mixed $v): string => (string) $v, $seq); |
||||
} |
||||
if ($preserved === []) { |
||||
$preserved = $this->defaultCategoryPreservedTags($ownerHex, $siteName); |
||||
} |
||||
|
||||
return [ |
||||
'd_tag' => $dTag, |
||||
'is_root' => $isRoot, |
||||
'title' => $title, |
||||
'summary' => $summary, |
||||
'content' => $event->getContent(), |
||||
'preserved_tags' => $preserved, |
||||
'a_coordinates' => $aCoords, |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* Category #d values that appear in a kind-30040 `a` tag on the root or any stored category |
||||
* but are not returned by {@see MagazineContentService::getCategorySlugsFromStore()} (e.g. newly |
||||
* linked before the next prewarm BFS). |
||||
* |
||||
* @param list<string> $alreadyListed |
||||
* |
||||
* @return list<string> |
||||
*/ |
||||
private function collectOrphan30040SlugsReferencedInStore(string $npub, string $rootD, string $ownerHex, array $alreadyListed): array |
||||
{ |
||||
$have = array_fill_keys($alreadyListed, true); |
||||
$out = []; |
||||
$events = []; |
||||
$r = $this->store->getRoot($npub, $rootD); |
||||
if ($r !== null) { |
||||
$events[] = $r; |
||||
} |
||||
foreach ($alreadyListed as $slug) { |
||||
$c = $this->store->getCategory($slug); |
||||
if ($c !== null) { |
||||
$events[] = $c; |
||||
} |
||||
} |
||||
foreach ($events as $event) { |
||||
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($event->getTags(), $ownerHex) as $childD) { |
||||
if ($childD === '' || hash_equals($rootD, $childD) || isset($have[$childD])) { |
||||
continue; |
||||
} |
||||
$have[$childD] = true; |
||||
$out[] = $childD; |
||||
} |
||||
} |
||||
|
||||
return $out; |
||||
} |
||||
} |
||||
@ -0,0 +1,270 @@
@@ -0,0 +1,270 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\User; |
||||
use App\Enum\KindsEnum; |
||||
use App\Util\NostrEventTags; |
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Event\Event as NostrWireEvent; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
|
||||
/** |
||||
* Validates NIP-07–signed kind-30040 magazine batches from the site owner, publishes to article relays, |
||||
* and updates {@see MagazineIndexStore}. |
||||
*/ |
||||
final class MagazineHierarchyPublishService |
||||
{ |
||||
private const STALE_EVENT_MAX_AGE_SEC = 600; |
||||
|
||||
public function __construct( |
||||
private readonly NostrClient $nostrClient, |
||||
private readonly MagazineIndexStore $store, |
||||
private readonly NostrWireEventMerge $wireMerge, |
||||
private readonly NostrKeyHelper $nostrKeyHelper, |
||||
private readonly ParameterBagInterface $params, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @param array<int, mixed> $rawEvents Decoded JSON event objects |
||||
* |
||||
* @return array{ok: true, published: int, stored: int}|array{ok: false, error: string, code: int} |
||||
*/ |
||||
public function publishOwnerMagazineBatch(User $user, array $rawEvents): array |
||||
{ |
||||
$npub = (string) $this->params->get('npub'); |
||||
$rootD = (string) $this->params->get('d_tag'); |
||||
$userNpub = $user->getNpub() ?? ''; |
||||
if ($userNpub === '' || !hash_equals($npub, $userNpub)) { |
||||
return ['ok' => false, 'error' => 'Only the configured magazine npub may publish this batch.', 'code' => 403]; |
||||
} |
||||
$ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); |
||||
if ($ownerHex === '' || 64 !== \strlen($ownerHex) || !ctype_xdigit($ownerHex)) { |
||||
return ['ok' => false, 'error' => 'Invalid owner pubkey configuration.', 'code' => 500]; |
||||
} |
||||
|
||||
if ($rawEvents === []) { |
||||
return ['ok' => false, 'error' => 'No events in batch.', 'code' => 400]; |
||||
} |
||||
if (\count($rawEvents) > 200) { |
||||
return ['ok' => false, 'error' => 'Too many events in one batch.', 'code' => 400]; |
||||
} |
||||
|
||||
/** @var array<string, NostrWireEvent> $byD */ |
||||
$byD = []; |
||||
foreach ($rawEvents as $i => $raw) { |
||||
if (!\is_array($raw)) { |
||||
return ['ok' => false, 'error' => 'Invalid event at index '.$i.'.', 'code' => 400]; |
||||
} |
||||
try { |
||||
$json = json_encode($raw, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); |
||||
} catch (\JsonException) { |
||||
return ['ok' => false, 'error' => 'Invalid JSON for event at index '.$i.'.', 'code' => 400]; |
||||
} |
||||
$wire = NostrWireEvent::fromVerified($json); |
||||
if ($wire === null) { |
||||
return ['ok' => false, 'error' => 'Invalid or unverifiable event at index '.$i.'.', 'code' => 400]; |
||||
} |
||||
if ($wire->getKind() !== KindsEnum::PUBLICATION_INDEX->value) { |
||||
return ['ok' => false, 'error' => 'Event at index '.$i.' must be kind 30040.', 'code' => 400]; |
||||
} |
||||
if (!hash_equals($ownerHex, strtolower($wire->getPublicKey()))) { |
||||
return ['ok' => false, 'error' => 'Event pubkey does not match the magazine owner.', 'code' => 403]; |
||||
} |
||||
$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 at index '.$i.'.', 'code' => 400]; |
||||
} |
||||
$d = $this->extractSingleDTag($wire->getTags()); |
||||
if ($d === '') { |
||||
return ['ok' => false, 'error' => 'Missing or duplicate #d tag at index '.$i.'.', 'code' => 400]; |
||||
} |
||||
if (isset($byD[$d])) { |
||||
return ['ok' => false, 'error' => 'Duplicate #d tag in batch: '.$d.'.', 'code' => 400]; |
||||
} |
||||
$err = $this->validateTagsForWire($wire->getTags(), $ownerHex); |
||||
if ($err !== null) { |
||||
return ['ok' => false, 'error' => $err.' (event #d '.$d.')', 'code' => 400]; |
||||
} |
||||
$byD[$d] = $wire; |
||||
} |
||||
|
||||
$graphErr = $this->validateGraphClosure($byD, $rootD); |
||||
if ($graphErr !== null) { |
||||
return ['ok' => false, 'error' => $graphErr, 'code' => 400]; |
||||
} |
||||
|
||||
$relays = $this->nostrClient->getArticleWriteRelayUrls(); |
||||
if ($relays === []) { |
||||
return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500]; |
||||
} |
||||
|
||||
$published = 0; |
||||
foreach ($byD as $wire) { |
||||
$res = $this->nostrClient->publishEvent($wire, $relays); |
||||
$okRelays = 0; |
||||
foreach ($res as $relayRes) { |
||||
if ($relayRes instanceof \Throwable) { |
||||
continue; |
||||
} |
||||
++$okRelays; |
||||
} |
||||
if ($okRelays < 1) { |
||||
$this->logger->warning('magazine_hierarchy.publish_failed_all_relays', [ |
||||
'id' => $wire->getId(), |
||||
]); |
||||
|
||||
return ['ok' => false, 'error' => 'Publish failed on all relays for event '.$wire->getId().'.', 'code' => 502]; |
||||
} |
||||
++$published; |
||||
} |
||||
|
||||
$stored = 0; |
||||
foreach ($byD as $d => $wire) { |
||||
$entity = $this->wireMerge->magazineEventToPublicationEntity(json_decode($wire->toJson(), true)); |
||||
if ($entity === null) { |
||||
return ['ok' => false, 'error' => 'Could not map event to storage model (#d '.$d.').', 'code' => 500]; |
||||
} |
||||
if (hash_equals($rootD, $d)) { |
||||
$this->store->putRoot($npub, $rootD, $entity); |
||||
} else { |
||||
$this->store->putCategory($d, $entity); |
||||
} |
||||
++$stored; |
||||
} |
||||
|
||||
$this->logger->info('magazine_hierarchy.published', [ |
||||
'published' => $published, |
||||
'stored' => $stored, |
||||
]); |
||||
|
||||
return ['ok' => true, 'published' => $published, 'stored' => $stored]; |
||||
} |
||||
|
||||
/** |
||||
* @param array<int, mixed> $tags |
||||
*/ |
||||
private function validateTagsForWire(array $tags, string $ownerHex): ?string |
||||
{ |
||||
foreach ($tags as $row) { |
||||
if (!NostrEventTags::tagNameMatches($row, 'a')) { |
||||
continue; |
||||
} |
||||
$seq = NostrEventTags::rowToStringList($row); |
||||
if ($seq === null || !isset($seq[1])) { |
||||
return 'Malformed `a` tag'; |
||||
} |
||||
$coord = trim((string) $seq[1]); |
||||
$parts = explode(':', $coord, 3); |
||||
if (\count($parts) < 3) { |
||||
return 'Invalid `a` coordinate'; |
||||
} |
||||
$kind = (int) $parts[0]; |
||||
$pk = strtolower(trim((string) $parts[1])); |
||||
$id = trim((string) $parts[2]); |
||||
if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||
return 'Invalid `a` coordinate pubkey or identifier'; |
||||
} |
||||
if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) { |
||||
return 'Nested 30040 `a` tags must use the magazine owner pubkey'; |
||||
} |
||||
if (!\in_array($kind, [ |
||||
KindsEnum::PUBLICATION_INDEX->value, |
||||
KindsEnum::LONGFORM->value, |
||||
KindsEnum::LONGFORM_DRAFT->value, |
||||
], true)) { |
||||
return 'Unsupported kind in `a` tag (only 30040, 30023, 30024)'; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param array<string, NostrWireEvent> $byD |
||||
*/ |
||||
private function validateGraphClosure(array $byD, string $rootD): ?string |
||||
{ |
||||
if (!isset($byD[$rootD])) { |
||||
return 'Batch must include the magazine root index (#d '.$rootD.').'; |
||||
} |
||||
|
||||
$queue = [$rootD]; |
||||
$visited = []; |
||||
while ($queue !== []) { |
||||
$d = array_shift($queue); |
||||
if ($d === '' || isset($visited[$d])) { |
||||
continue; |
||||
} |
||||
$visited[$d] = true; |
||||
$ev = $byD[$d]; |
||||
foreach ($ev->getTags() as $row) { |
||||
if (!NostrEventTags::tagNameMatches($row, 'a')) { |
||||
continue; |
||||
} |
||||
$seq = NostrEventTags::rowToStringList($row); |
||||
if ($seq === null || !isset($seq[1])) { |
||||
continue; |
||||
} |
||||
$coord = trim((string) $seq[1]); |
||||
$parts = explode(':', $coord, 3); |
||||
if (\count($parts) < 3) { |
||||
return 'Malformed nested coordinate under #d '.$d; |
||||
} |
||||
$kind = (int) $parts[0]; |
||||
if ($kind !== KindsEnum::PUBLICATION_INDEX->value) { |
||||
continue; |
||||
} |
||||
$childD = trim((string) $parts[2]); |
||||
if ($childD === '') { |
||||
return 'Empty nested magazine #d under #d '.$d; |
||||
} |
||||
if (!isset($byD[$childD])) { |
||||
return 'Every nested kind-30040 `a` tag must have a matching event in this batch (missing #d '.$childD.' referenced from '.$d.').'; |
||||
} |
||||
if (!isset($visited[$childD])) { |
||||
$queue[] = $childD; |
||||
} |
||||
} |
||||
} |
||||
|
||||
foreach (array_keys($byD) as $d) { |
||||
if (!isset($visited[$d])) { |
||||
return 'Event #d '.$d.' is not reachable from the magazine root via kind-30040 links.'; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param array<int, mixed> $tags |
||||
*/ |
||||
private function extractSingleDTag(array $tags): string |
||||
{ |
||||
$found = ''; |
||||
foreach ($tags as $row) { |
||||
if (!NostrEventTags::tagNameMatches($row, 'd')) { |
||||
continue; |
||||
} |
||||
$seq = NostrEventTags::rowToStringList($row); |
||||
if ($seq === null || !isset($seq[1])) { |
||||
continue; |
||||
} |
||||
$v = trim((string) $seq[1]); |
||||
if ($v === '') { |
||||
continue; |
||||
} |
||||
if ($found !== '') { |
||||
return ''; |
||||
} |
||||
$found = $v; |
||||
} |
||||
|
||||
return $found; |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block title %}Magazine index editor — {{ website_name }}{% endblock %} |
||||
|
||||
{% block meta_description %} |
||||
<meta name="description" content="{{ 'Edit kind-30040 magazine hierarchy (owner only).'|e('html_attr') }}"> |
||||
{% endblock %} |
||||
|
||||
{% block body %} |
||||
<div class="card magazine-editor"> |
||||
<div class="card-header"> |
||||
<h1 class="card-title">Magazine index editor</h1> |
||||
</div> |
||||
<div class="card-body"> |
||||
<p class="magazine-editor__intro"> |
||||
Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Only indices you changed are signed; any other index required so nested <code>a</code> links stay consistent is still included in the same batch (same rules as before). |
||||
</p> |
||||
|
||||
<div |
||||
class="magazine-editor__panel" |
||||
data-controller="magazine-hierarchy-editor" |
||||
data-magazine-hierarchy-editor-publish-url-value="{{ path('magazine_edit_publish') }}" |
||||
data-magazine-hierarchy-editor-csrf-value="{{ magazine_edit_csrf }}" |
||||
data-magazine-hierarchy-editor-owner-hex-value="{{ editor_payload.owner_hex }}" |
||||
data-magazine-hierarchy-editor-root-d-tag-value="{{ editor_payload.root_d_tag|e('html_attr') }}" |
||||
> |
||||
<p class="magazine-editor__status" data-magazine-hierarchy-editor-target="status" aria-live="polite"></p> |
||||
|
||||
<div class="magazine-editor__nodes"> |
||||
{% for node in editor_payload.nodes %} |
||||
{% set depth = node.depth|default(0) %} |
||||
<fieldset |
||||
class="magazine-editor__node{% if depth > 1 %} magazine-editor__node--nested{% endif %}" |
||||
style="--mag-node-depth: {{ depth }}; margin-left: calc(max(0, var(--mag-node-depth) - 1) * 1.15rem);" |
||||
data-magazine-hierarchy-editor-target="node" |
||||
> |
||||
<legend class="magazine-editor__legend"> |
||||
{% if node.is_root %} |
||||
Root index |
||||
{% elseif depth > 1 %} |
||||
Subcategory |
||||
{% else %} |
||||
Category |
||||
{% endif %} |
||||
<code class="magazine-editor__slug">{{ node.d_tag }}</code> |
||||
</legend> |
||||
|
||||
<input type="hidden" data-magazine-hierarchy-editor-target="preservedJson" value="{{ node.preserved_tags|json_encode(constant('JSON_UNESCAPED_UNICODE'))|e('html_attr') }}"> |
||||
|
||||
<label class="magazine-editor__label"> |
||||
<span class="magazine-editor__label-text">Title</span> |
||||
<input type="text" class="magazine-editor__input" data-magazine-hierarchy-editor-target="title" value="{{ node.title|e('html_attr') }}" autocomplete="off"> |
||||
</label> |
||||
|
||||
<label class="magazine-editor__label"> |
||||
<span class="magazine-editor__label-text">Summary</span> |
||||
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="summary" rows="3">{{ node.summary }}</textarea> |
||||
</label> |
||||
|
||||
<label class="magazine-editor__label"> |
||||
<span class="magazine-editor__label-text">Content</span> |
||||
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="content" rows="2">{{ node.content }}</textarea> |
||||
</label> |
||||
|
||||
<label class="magazine-editor__label"> |
||||
<span class="magazine-editor__label-text"><code>a</code> coordinates (one per line: <code>kind:hex64pubkey:identifier</code>, long-form <code>30023</code>/<code>30024</code>, or NIP-19 <code>naddr1…</code> / <code>nostr:naddr1…</code> — naddrs are expanded before signing)</span> |
||||
<textarea class="magazine-editor__textarea magazine-editor__textarea--mono" data-magazine-hierarchy-editor-target="aLines" rows="8" spellcheck="false">{{ node.a_coordinates|join("\n") }}</textarea> |
||||
</label> |
||||
|
||||
<input type="hidden" data-magazine-hierarchy-editor-target="dTag" value="{{ node.d_tag|e('html_attr') }}"> |
||||
</fieldset> |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
<div class="magazine-editor__actions"> |
||||
<button type="button" data-action="click->magazine-hierarchy-editor#publish"> |
||||
Sign and publish all changed events |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
Loading…
Reference in new issue