You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

476 lines
16 KiB

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}`;
}